@hed-hog/core 0.0.302 → 0.0.303

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.
@@ -6,7 +6,6 @@ import {
6
6
  PageHeader,
7
7
  PaginationFooter,
8
8
  SearchBar,
9
- StatsCards,
10
9
  } from '@/components/entity-list';
11
10
  import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
12
11
  import {
@@ -23,17 +22,25 @@ import { Badge } from '@/components/ui/badge';
23
22
  import { Button } from '@/components/ui/button';
24
23
  import {
25
24
  Card,
25
+ CardContent,
26
26
  CardDescription,
27
27
  CardHeader,
28
28
  CardTitle,
29
29
  } from '@/components/ui/card';
30
30
  import {
31
- Dialog,
32
- DialogContent,
33
- DialogDescription,
34
- DialogHeader,
35
- DialogTitle,
36
- } from '@/components/ui/dialog';
31
+ ContextMenu,
32
+ ContextMenuContent,
33
+ ContextMenuItem,
34
+ ContextMenuSeparator,
35
+ ContextMenuTrigger,
36
+ } from '@/components/ui/context-menu';
37
+ import {
38
+ Drawer,
39
+ DrawerContent,
40
+ DrawerDescription,
41
+ DrawerHeader,
42
+ DrawerTitle,
43
+ } from '@/components/ui/drawer';
37
44
  import {
38
45
  Form,
39
46
  FormControl,
@@ -43,7 +50,14 @@ import {
43
50
  FormMessage,
44
51
  } from '@/components/ui/form';
45
52
  import { Input } from '@/components/ui/input';
53
+ import { KpiCardsGrid } from '@/components/ui/kpi-cards-grid';
46
54
  import { Label } from '@/components/ui/label';
55
+ import {
56
+ ResizableHandle,
57
+ ResizablePanel,
58
+ ResizablePanelGroup,
59
+ } from '@/components/ui/resizable';
60
+ import { ScrollArea } from '@/components/ui/scroll-area';
47
61
  import {
48
62
  Select,
49
63
  SelectContent,
@@ -60,19 +74,44 @@ import {
60
74
  } from '@/components/ui/sheet';
61
75
  import { Switch } from '@/components/ui/switch';
62
76
  import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
77
+ import {
78
+ Tooltip,
79
+ TooltipContent,
80
+ TooltipTrigger,
81
+ } from '@/components/ui/tooltip';
82
+ import { cn } from '@/lib/utils';
63
83
  import { useApp, useQuery } from '@hed-hog/next-app-provider';
64
84
  import { zodResolver } from '@hookform/resolvers/zod';
65
85
  import {
86
+ BadgeHelp,
87
+ BookOpen,
88
+ Building2,
89
+ ChevronDown,
90
+ ChevronRight,
91
+ Copy,
92
+ FileText,
93
+ FolderPlus,
66
94
  GitBranch,
95
+ GripVertical,
96
+ House,
97
+ LayoutDashboard,
67
98
  Link,
68
99
  Loader2,
69
100
  Menu,
101
+ PanelLeftOpen,
70
102
  Plus,
103
+ RefreshCcw,
104
+ Settings,
71
105
  ShieldCheck,
106
+ Tag,
107
+ Ticket,
72
108
  Trash2,
109
+ Users,
110
+ Wallet,
111
+ type LucideIcon,
73
112
  } from 'lucide-react';
74
113
  import { useTranslations } from 'next-intl';
75
- import React, { useEffect, useState } from 'react';
114
+ import React, { useCallback, useEffect, useMemo, useState } from 'react';
76
115
  import { useForm } from 'react-hook-form';
77
116
  import { toast } from 'sonner';
78
117
  import { z } from 'zod';
@@ -89,11 +128,18 @@ type RoleLocale = {
89
128
  description?: string;
90
129
  };
91
130
 
131
+ type RoleAccessUser = {
132
+ id: number;
133
+ name?: string | null;
134
+ user_identifier?: Array<{ value: string }>;
135
+ };
136
+
92
137
  type RoleItem = {
93
138
  id: number;
94
139
  name?: string;
95
140
  role_locale?: RoleLocale[];
96
141
  role_menu?: Array<{ role_id: number; menu_id: number }>;
142
+ role_user?: Array<{ user_id: number; user: RoleAccessUser }>;
97
143
  };
98
144
 
99
145
  type MenuRolesSectionProps = {
@@ -121,10 +167,7 @@ function MenuRolesSection({ menuId }: MenuRolesSectionProps) {
121
167
  enabled: !!menuId,
122
168
  });
123
169
 
124
- const roles = rolesData?.data ?? [];
125
-
126
- const isRoleAssigned = (role: RoleItem) =>
127
- !!(role.role_menu && role.role_menu.length > 0);
170
+ const roles = useMemo(() => rolesData?.data ?? [], [rolesData?.data]);
128
171
 
129
172
  const getRoleName = (role: RoleItem) =>
130
173
  role.role_locale?.[0]?.name ?? role.name ?? String(role.id);
@@ -132,12 +175,61 @@ function MenuRolesSection({ menuId }: MenuRolesSectionProps) {
132
175
  const getRoleDescription = (role: RoleItem) =>
133
176
  role.role_locale?.[0]?.description ?? '';
134
177
 
178
+ const getRoleUserCount = (role: RoleItem) => role.role_user?.length ?? 0;
179
+
180
+ const assignedRoles = useMemo(
181
+ () => roles.filter((role) => Boolean(role.role_menu?.length)),
182
+ [roles]
183
+ );
184
+
185
+ const accessPreviewUsers = useMemo(() => {
186
+ const previewMap = new Map<
187
+ number,
188
+ { id: number; name: string; email: string | null; roles: string[] }
189
+ >();
190
+
191
+ assignedRoles.forEach((role) => {
192
+ const roleLabel =
193
+ role.role_locale?.[0]?.name ?? role.name ?? String(role.id);
194
+
195
+ role.role_user?.forEach((roleUser) => {
196
+ const user = roleUser.user;
197
+
198
+ if (!user) {
199
+ return;
200
+ }
201
+
202
+ const email = user.user_identifier?.[0]?.value ?? null;
203
+ const name = user.name?.trim() || email || `#${user.id}`;
204
+ const existing = previewMap.get(user.id) ?? {
205
+ id: user.id,
206
+ name,
207
+ email,
208
+ roles: [],
209
+ };
210
+
211
+ if (!existing.roles.includes(roleLabel)) {
212
+ existing.roles.push(roleLabel);
213
+ }
214
+
215
+ previewMap.set(user.id, existing);
216
+ });
217
+ });
218
+
219
+ return Array.from(previewMap.values()).sort((left, right) =>
220
+ left.name.localeCompare(right.name)
221
+ );
222
+ }, [assignedRoles]);
223
+
224
+ const previewUsers = accessPreviewUsers.slice(0, 6);
225
+
135
226
  const handleToggle = async (roleId: number, currentlyAssigned: boolean) => {
136
227
  setTogglingRoleId(roleId);
228
+
137
229
  try {
138
230
  const currentIds = roles
139
- .filter((r) => isRoleAssigned(r))
140
- .map((r) => r.id);
231
+ .filter((role) => Boolean(role.role_menu?.length))
232
+ .map((role) => role.id);
141
233
 
142
234
  const newIds = currentlyAssigned
143
235
  ? currentIds.filter((id) => id !== roleId)
@@ -171,67 +263,83 @@ function MenuRolesSection({ menuId }: MenuRolesSectionProps) {
171
263
 
172
264
  if (!roles.length) {
173
265
  return (
174
- <div className="border border-dashed rounded-lg p-8 flex flex-col items-center justify-center">
175
- <ShieldCheck className="h-10 w-10 text-muted-foreground mb-3" />
176
- <p className="text-sm font-medium text-center text-muted-foreground">
177
- {t('noRolesFound')}
178
- </p>
179
- </div>
266
+ <EmptyState
267
+ className="min-h-60 py-10"
268
+ icon={<ShieldCheck className="h-10 w-10" />}
269
+ title={t('noRolesFound')}
270
+ description={t('rolesDescription')}
271
+ />
180
272
  );
181
273
  }
182
274
 
183
275
  return (
184
276
  <div className="space-y-3">
185
277
  <div>
186
- <h4 className="text-sm font-semibold flex items-center gap-2">
278
+ <h4 className="flex items-center gap-2 text-sm font-semibold">
187
279
  <ShieldCheck className="h-4 w-4" />
188
280
  {t('rolesTitle')}
189
281
  </h4>
190
- <p className="text-xs text-muted-foreground mt-1">
282
+ <p className="mt-0.5 text-xs text-muted-foreground">
191
283
  {t('rolesDescription')}
192
284
  </p>
193
285
  </div>
194
286
 
195
- <div className="space-y-2">
287
+ <div className="space-y-1.5">
196
288
  {roles.map((role) => {
197
- const assigned = isRoleAssigned(role);
289
+ const assigned = Boolean(role.role_menu?.length);
198
290
  const toggling = togglingRoleId === role.id;
291
+ const linkedUsers = getRoleUserCount(role);
199
292
 
200
293
  return (
201
294
  <div
202
295
  key={role.id}
203
- className={`flex items-center justify-between p-3 rounded-md border transition-all ${
296
+ className={cn(
297
+ 'flex items-center justify-between rounded-lg border p-2.5 transition-all duration-150 hover:shadow-sm active:scale-[0.99]',
204
298
  assigned
205
299
  ? 'border-primary/50 bg-primary/5'
206
300
  : 'border-border hover:border-primary/30'
207
- }`}
301
+ )}
208
302
  >
209
- <div className="flex items-center gap-3 flex-1 min-w-0">
303
+ <div className="flex min-w-0 flex-1 items-center gap-2.5">
210
304
  <div
211
- className={`rounded-md p-2 shrink-0 ${
305
+ className={cn(
306
+ 'shrink-0 rounded-md p-1.5',
212
307
  assigned ? 'bg-primary/10' : 'bg-muted'
213
- }`}
308
+ )}
214
309
  >
215
310
  <ShieldCheck
216
- className={`h-4 w-4 ${
311
+ className={cn(
312
+ 'h-4 w-4',
217
313
  assigned ? 'text-primary' : 'text-muted-foreground'
218
- }`}
314
+ )}
219
315
  />
220
316
  </div>
221
- <div className="flex-1 min-w-0">
222
- <Label
223
- htmlFor={`role-${role.id}`}
224
- className="text-sm font-medium cursor-pointer"
225
- >
226
- {getRoleName(role)}
227
- </Label>
228
- {getRoleDescription(role) && (
229
- <p className="text-xs text-muted-foreground mt-0.5 truncate">
317
+
318
+ <div className="min-w-0 flex-1">
319
+ <div className="flex flex-wrap items-center gap-1.5">
320
+ <Label
321
+ htmlFor={`role-${role.id}`}
322
+ className="cursor-pointer text-sm font-medium"
323
+ >
324
+ {getRoleName(role)}
325
+ </Label>
326
+ <TooltipBadge
327
+ tooltip={`${t('usersWithAccessTitle')}: ${linkedUsers}`}
328
+ variant="outline"
329
+ className="rounded-full px-1.5 py-0 text-[10px]"
330
+ >
331
+ <Users className="mr-1 h-3 w-3" />
332
+ {linkedUsers}
333
+ </TooltipBadge>
334
+ </div>
335
+ {getRoleDescription(role) ? (
336
+ <p className="mt-0.5 truncate text-xs text-muted-foreground">
230
337
  {getRoleDescription(role)}
231
338
  </p>
232
- )}
339
+ ) : null}
233
340
  </div>
234
341
  </div>
342
+
235
343
  <Switch
236
344
  id={`role-${role.id}`}
237
345
  checked={assigned}
@@ -243,6 +351,92 @@ function MenuRolesSection({ menuId }: MenuRolesSectionProps) {
243
351
  );
244
352
  })}
245
353
  </div>
354
+
355
+ <div className="rounded-xl border bg-muted/20 p-3">
356
+ <div className="flex items-start justify-between gap-2">
357
+ <div>
358
+ <h5 className="flex items-center gap-2 text-sm font-semibold">
359
+ <Users className="h-4 w-4" />
360
+ {t('usersWithAccessTitle')}
361
+ </h5>
362
+ <p className="mt-0.5 text-xs text-muted-foreground">
363
+ {t('usersWithAccessDescription')}
364
+ </p>
365
+ </div>
366
+
367
+ <TooltipBadge
368
+ tooltip={`${t('usersWithAccessTitle')}: ${accessPreviewUsers.length}`}
369
+ variant="secondary"
370
+ className="rounded-full px-2 py-0.5"
371
+ >
372
+ {accessPreviewUsers.length}
373
+ </TooltipBadge>
374
+ </div>
375
+
376
+ {!assignedRoles.length ? (
377
+ <p className="mt-3 text-xs text-muted-foreground">
378
+ {t('noAssignedRolesPreview')}
379
+ </p>
380
+ ) : !accessPreviewUsers.length ? (
381
+ <p className="mt-3 text-xs text-muted-foreground">
382
+ {t('noUsersWithAccess')}
383
+ </p>
384
+ ) : (
385
+ <div className="mt-3 space-y-2">
386
+ {previewUsers.map((user) => (
387
+ <div
388
+ key={user.id}
389
+ className="rounded-lg border bg-background/80 p-2.5"
390
+ >
391
+ <div className="flex items-start justify-between gap-2">
392
+ <div className="min-w-0">
393
+ <p className="truncate text-sm font-medium">{user.name}</p>
394
+ {user.email ? (
395
+ <p className="truncate text-xs text-muted-foreground">
396
+ {user.email}
397
+ </p>
398
+ ) : null}
399
+ </div>
400
+
401
+ <TooltipBadge
402
+ tooltip={`${t('grantedByRole')}: ${user.roles.length}`}
403
+ variant="outline"
404
+ className="rounded-full px-2 py-0 text-[10px]"
405
+ >
406
+ {user.roles.length}
407
+ </TooltipBadge>
408
+ </div>
409
+
410
+ <div className="mt-2">
411
+ <p className="text-[10px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">
412
+ {t('grantedByRole')}
413
+ </p>
414
+ <div className="mt-1 flex flex-wrap gap-1">
415
+ {user.roles.map((roleLabel) => (
416
+ <TooltipBadge
417
+ key={`${user.id}-${roleLabel}`}
418
+ tooltip={roleLabel}
419
+ variant="secondary"
420
+ className="rounded-full px-2 py-0 text-[10px]"
421
+ >
422
+ {roleLabel}
423
+ </TooltipBadge>
424
+ ))}
425
+ </div>
426
+ </div>
427
+ </div>
428
+ ))}
429
+
430
+ {accessPreviewUsers.length > previewUsers.length ? (
431
+ <p className="text-xs text-muted-foreground">
432
+ {t('moreUsers', {
433
+ count: accessPreviewUsers.length - previewUsers.length,
434
+ })}
435
+ </p>
436
+ ) : null}
437
+ </div>
438
+ )}
439
+ </div>
246
440
  </div>
247
441
  );
248
442
  }
@@ -256,16 +450,10 @@ type TreeNode = {
256
450
  order: number | null;
257
451
  name?: string;
258
452
  menu_locale?: Array<{ name?: string; locale?: { code: string } }>;
453
+ _count?: { role_menu?: number };
259
454
  children: TreeNode[];
260
455
  };
261
456
 
262
- type MenuTreeDialogProps = {
263
- open: boolean;
264
- onClose: () => void;
265
- menus: TreeNode[];
266
- onSaved: () => void;
267
- };
268
-
269
457
  type DropPosition = 'before' | 'inside' | 'after';
270
458
 
271
459
  type DropTarget = {
@@ -276,8 +464,10 @@ type DropTarget = {
276
464
 
277
465
  function buildTree(flat: TreeNode[]): TreeNode[] {
278
466
  const map = new Map<number, TreeNode>();
279
- flat.forEach((m) => map.set(m.id, { ...m, children: [] }));
467
+ flat.forEach((menu) => map.set(menu.id, { ...menu, children: [] }));
468
+
280
469
  const roots: TreeNode[] = [];
470
+
281
471
  map.forEach((node) => {
282
472
  if (node.menu_id != null && map.has(node.menu_id)) {
283
473
  map.get(node.menu_id)!.children.push(node);
@@ -285,10 +475,12 @@ function buildTree(flat: TreeNode[]): TreeNode[] {
285
475
  roots.push(node);
286
476
  }
287
477
  });
288
- const sortByOrder = (arr: TreeNode[]): TreeNode[] =>
289
- arr
478
+
479
+ const sortByOrder = (nodes: TreeNode[]): TreeNode[] =>
480
+ nodes
290
481
  .sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
291
- .map((n) => ({ ...n, children: sortByOrder(n.children) }));
482
+ .map((node) => ({ ...node, children: sortByOrder(node.children) }));
483
+
292
484
  return sortByOrder(roots);
293
485
  }
294
486
 
@@ -297,16 +489,23 @@ function removeNodeFromTree(
297
489
  id: number
298
490
  ): { tree: TreeNode[]; removed: TreeNode | null } {
299
491
  let removed: TreeNode | null = null;
300
- const process = (arr: TreeNode[]): TreeNode[] => {
301
- const filtered = arr.filter((n) => {
302
- if (n.id === id) {
303
- removed = n;
492
+
493
+ const process = (items: TreeNode[]): TreeNode[] => {
494
+ const filtered = items.filter((node) => {
495
+ if (node.id === id) {
496
+ removed = node;
304
497
  return false;
305
498
  }
499
+
306
500
  return true;
307
501
  });
308
- return filtered.map((n) => ({ ...n, children: process(n.children) }));
502
+
503
+ return filtered.map((node) => ({
504
+ ...node,
505
+ children: process(node.children),
506
+ }));
309
507
  };
508
+
310
509
  return { tree: process(nodes), removed };
311
510
  }
312
511
 
@@ -317,27 +516,33 @@ function insertIntoTree(
317
516
  position: DropPosition
318
517
  ): TreeNode[] {
319
518
  if (position === 'inside') {
320
- return nodes.map((n) => {
321
- if (n.id === targetId) {
322
- return { ...n, children: [...n.children, node] };
519
+ return nodes.map((item) => {
520
+ if (item.id === targetId) {
521
+ return { ...item, children: [...item.children, node] };
323
522
  }
523
+
324
524
  return {
325
- ...n,
326
- children: insertIntoTree(n.children, node, targetId, position),
525
+ ...item,
526
+ children: insertIntoTree(item.children, node, targetId, position),
327
527
  };
328
528
  });
329
529
  }
330
530
 
331
- const idx = nodes.findIndex((n) => n.id === targetId);
332
- if (idx !== -1) {
531
+ const targetIndex = nodes.findIndex((item) => item.id === targetId);
532
+
533
+ if (targetIndex !== -1) {
333
534
  const result = [...nodes];
334
- result.splice(position === 'before' ? idx : idx + 1, 0, node);
535
+ result.splice(
536
+ position === 'before' ? targetIndex : targetIndex + 1,
537
+ 0,
538
+ node
539
+ );
335
540
  return result;
336
541
  }
337
542
 
338
- return nodes.map((n) => ({
339
- ...n,
340
- children: insertIntoTree(n.children, node, targetId, position),
543
+ return nodes.map((item) => ({
544
+ ...item,
545
+ children: insertIntoTree(item.children, node, targetId, position),
341
546
  }));
342
547
  }
343
548
 
@@ -346,39 +551,245 @@ function isDescendantOf(
346
551
  ancestorId: number,
347
552
  candidateId: number
348
553
  ): boolean {
349
- const findAncestor = (arr: TreeNode[]): TreeNode | null => {
350
- for (const n of arr) {
351
- if (n.id === ancestorId) return n;
352
- const found = findAncestor(n.children);
353
- if (found) return found;
554
+ const findAncestor = (items: TreeNode[]): TreeNode | null => {
555
+ for (const node of items) {
556
+ if (node.id === ancestorId) {
557
+ return node;
558
+ }
559
+
560
+ const found = findAncestor(node.children);
561
+ if (found) {
562
+ return found;
563
+ }
354
564
  }
565
+
355
566
  return null;
356
567
  };
568
+
357
569
  const ancestor = findAncestor(nodes);
358
- if (!ancestor) return false;
359
- const searchInChildren = (children: TreeNode[]): boolean =>
360
- children.some((c) => c.id === candidateId || searchInChildren(c.children));
361
- return searchInChildren(ancestor.children);
570
+
571
+ if (!ancestor) {
572
+ return false;
573
+ }
574
+
575
+ const searchChildren = (items: TreeNode[]): boolean =>
576
+ items.some(
577
+ (node) => node.id === candidateId || searchChildren(node.children)
578
+ );
579
+
580
+ return searchChildren(ancestor.children);
362
581
  }
363
582
 
364
583
  function collectOrderGroups(nodes: TreeNode[]): { ids: number[] }[] {
365
584
  const groups: { ids: number[] }[] = [];
585
+
366
586
  const traverse = (siblings: TreeNode[]) => {
367
587
  if (siblings.length > 0) {
368
- groups.push({ ids: siblings.map((n) => n.id) });
369
- siblings.forEach((n) => traverse(n.children));
588
+ groups.push({ ids: siblings.map((node) => node.id) });
589
+ siblings.forEach((node) => traverse(node.children));
370
590
  }
371
591
  };
592
+
372
593
  traverse(nodes);
373
594
  return groups;
374
595
  }
375
596
 
376
- function MenuTreeDialog({
377
- open,
378
- onClose,
597
+ function collectExpandableIds(nodes: TreeNode[]): number[] {
598
+ const ids: number[] = [];
599
+
600
+ const traverse = (items: TreeNode[]) => {
601
+ items.forEach((node) => {
602
+ if (node.children.length > 0) {
603
+ ids.push(node.id);
604
+ traverse(node.children);
605
+ }
606
+ });
607
+ };
608
+
609
+ traverse(nodes);
610
+ return ids;
611
+ }
612
+
613
+ function filterTreeNodes(nodes: TreeNode[], query: string): TreeNode[] {
614
+ const normalizedQuery = query.trim().toLowerCase();
615
+
616
+ if (!normalizedQuery) {
617
+ return nodes;
618
+ }
619
+
620
+ return nodes.flatMap((node) => {
621
+ const filteredChildren = filterTreeNodes(node.children, query);
622
+ const label = node.menu_locale?.[0]?.name ?? node.name ?? node.slug;
623
+ const matches = [label, node.slug, node.url]
624
+ .filter(Boolean)
625
+ .some((value) => value!.toLowerCase().includes(normalizedQuery));
626
+
627
+ if (!matches && filteredChildren.length === 0) {
628
+ return [];
629
+ }
630
+
631
+ return [{ ...node, children: filteredChildren }];
632
+ });
633
+ }
634
+
635
+ function getDescendantIds(menus: MenuItem[], parentId: number): number[] {
636
+ const descendants: number[] = [];
637
+ const stack = menus
638
+ .filter((menu) => menu.menu_id === parentId)
639
+ .map((menu) => menu.id);
640
+
641
+ while (stack.length > 0) {
642
+ const currentId = stack.pop();
643
+
644
+ if (currentId == null) {
645
+ continue;
646
+ }
647
+
648
+ descendants.push(currentId);
649
+
650
+ menus
651
+ .filter((menu) => menu.menu_id === currentId)
652
+ .forEach((menu) => stack.push(menu.id));
653
+ }
654
+
655
+ return descendants;
656
+ }
657
+
658
+ function getMenuRoleCount(
659
+ menu: Pick<MenuItem, '_count'> | Pick<TreeNode, '_count'>
660
+ ) {
661
+ return menu._count?.role_menu ?? 0;
662
+ }
663
+
664
+ type MenuVisualConfig = {
665
+ Icon: LucideIcon;
666
+ iconWrapperClassName: string;
667
+ iconClassName: string;
668
+ };
669
+
670
+ function getMenuVisual(menu: {
671
+ slug: string;
672
+ url: string;
673
+ name?: string;
674
+ icon?: string | null;
675
+ }): MenuVisualConfig {
676
+ const lookup =
677
+ `${menu.slug} ${menu.url} ${menu.name ?? ''} ${menu.icon ?? ''}`.toLowerCase();
678
+
679
+ if (/(dashboard|painel|home|inicio)/.test(lookup)) {
680
+ return {
681
+ Icon: LayoutDashboard,
682
+ iconWrapperClassName: 'bg-blue-50',
683
+ iconClassName: 'text-blue-600',
684
+ };
685
+ }
686
+
687
+ if (/(user|usuario|account|conta|contact|cliente|cargo|role)/.test(lookup)) {
688
+ return {
689
+ Icon: Users,
690
+ iconWrapperClassName: 'bg-violet-50',
691
+ iconClassName: 'text-violet-600',
692
+ };
693
+ }
694
+
695
+ if (/(config|setting|admin|security|permission)/.test(lookup)) {
696
+ return {
697
+ Icon: Settings,
698
+ iconWrapperClassName: 'bg-slate-100',
699
+ iconClassName: 'text-slate-700',
700
+ };
701
+ }
702
+
703
+ if (
704
+ /(finance|billing|payment|bank|cash|invoice|transfer|credit|debit)/.test(
705
+ lookup
706
+ )
707
+ ) {
708
+ return {
709
+ Icon: Wallet,
710
+ iconWrapperClassName: 'bg-emerald-50',
711
+ iconClassName: 'text-emerald-600',
712
+ };
713
+ }
714
+
715
+ if (/(faq|help|support|ajuda)/.test(lookup)) {
716
+ return {
717
+ Icon: BadgeHelp,
718
+ iconWrapperClassName: 'bg-amber-50',
719
+ iconClassName: 'text-amber-600',
720
+ };
721
+ }
722
+
723
+ if (/(ticket|chamado|suporte)/.test(lookup)) {
724
+ return {
725
+ Icon: Ticket,
726
+ iconWrapperClassName: 'bg-rose-50',
727
+ iconClassName: 'text-rose-600',
728
+ };
729
+ }
730
+
731
+ if (/(blog|content|post|article|template|email|report|doc)/.test(lookup)) {
732
+ return {
733
+ Icon: FileText,
734
+ iconWrapperClassName: 'bg-cyan-50',
735
+ iconClassName: 'text-cyan-600',
736
+ };
737
+ }
738
+
739
+ if (/(course|lesson|lms|class|academy)/.test(lookup)) {
740
+ return {
741
+ Icon: BookOpen,
742
+ iconWrapperClassName: 'bg-indigo-50',
743
+ iconClassName: 'text-indigo-600',
744
+ };
745
+ }
746
+
747
+ if (/(tag|label|category|catalog)/.test(lookup)) {
748
+ return {
749
+ Icon: Tag,
750
+ iconWrapperClassName: 'bg-fuchsia-50',
751
+ iconClassName: 'text-fuchsia-600',
752
+ };
753
+ }
754
+
755
+ if (/(company|business|operation|studio|workspace)/.test(lookup)) {
756
+ return {
757
+ Icon: Building2,
758
+ iconWrapperClassName: 'bg-orange-50',
759
+ iconClassName: 'text-orange-600',
760
+ };
761
+ }
762
+
763
+ return {
764
+ Icon: Menu,
765
+ iconWrapperClassName: 'bg-muted',
766
+ iconClassName: 'text-muted-foreground',
767
+ };
768
+ }
769
+
770
+ type MenuTreeWorkspaceProps = {
771
+ menus: TreeNode[];
772
+ searchQuery: string;
773
+ selectedId: number | null;
774
+ onSelect: (menuId: number) => void | Promise<void>;
775
+ onSaved: () => void | Promise<void>;
776
+ onAddSubmenu: (menuId: number) => void | Promise<void>;
777
+ onDuplicate: (menuId: number) => void | Promise<void>;
778
+ onDelete: (menuId: number) => void | Promise<void>;
779
+ onMoveToRoot: (menuId: number) => void | Promise<void>;
780
+ };
781
+
782
+ function MenuTreeWorkspace({
379
783
  menus,
784
+ searchQuery,
785
+ selectedId,
786
+ onSelect,
380
787
  onSaved,
381
- }: MenuTreeDialogProps) {
788
+ onAddSubmenu,
789
+ onDuplicate,
790
+ onDelete,
791
+ onMoveToRoot,
792
+ }: MenuTreeWorkspaceProps) {
382
793
  const t = useTranslations('core.MenuPage');
383
794
  const { request } = useApp();
384
795
 
@@ -389,16 +800,36 @@ function MenuTreeDialog({
389
800
  parentId: number | null;
390
801
  } | null>(null);
391
802
  const [dropTarget, setDropTarget] = useState<DropTarget>(null);
803
+ const [expandedIds, setExpandedIds] = useState<Set<number>>(new Set());
392
804
 
393
805
  useEffect(() => {
394
- if (open) {
395
- setTree(buildTree(menus as TreeNode[]));
396
- }
397
- }, [open, menus]);
806
+ const nextTree = buildTree(menus as TreeNode[]);
807
+ setTree(nextTree);
808
+ setExpandedIds(new Set(collectExpandableIds(nextTree)));
809
+ }, [menus]);
810
+
811
+ const filteredTree = useMemo(
812
+ () => filterTreeNodes(tree, searchQuery),
813
+ [searchQuery, tree]
814
+ );
398
815
 
399
816
  const getDisplayName = (node: TreeNode) =>
400
817
  node.menu_locale?.[0]?.name ?? node.name ?? node.slug;
401
818
 
819
+ const toggleExpanded = (id: number) => {
820
+ setExpandedIds((previous) => {
821
+ const next = new Set(previous);
822
+
823
+ if (next.has(id)) {
824
+ next.delete(id);
825
+ } else {
826
+ next.add(id);
827
+ }
828
+
829
+ return next;
830
+ });
831
+ };
832
+
402
833
  const handleDrop = async (target: DropTarget) => {
403
834
  if (!target || !dragging || dragging.id === target.id) {
404
835
  setDragging(null);
@@ -420,10 +851,11 @@ function MenuTreeDialog({
420
851
  const oldParentId = dragging.parentId;
421
852
  const draggedId = dragging.id;
422
853
 
423
- const { tree: treeWithout, removed } = removeNodeFromTree(
854
+ const { tree: treeWithoutNode, removed } = removeNodeFromTree(
424
855
  tree,
425
856
  dragging.id
426
857
  );
858
+
427
859
  if (!removed) {
428
860
  setDragging(null);
429
861
  setDropTarget(null);
@@ -431,16 +863,18 @@ function MenuTreeDialog({
431
863
  }
432
864
 
433
865
  const newTree = insertIntoTree(
434
- treeWithout,
866
+ treeWithoutNode,
435
867
  removed,
436
868
  target.id,
437
869
  target.position
438
870
  );
871
+
439
872
  setTree(newTree);
440
873
  setDragging(null);
441
874
  setDropTarget(null);
442
875
 
443
876
  setSaving(true);
877
+
444
878
  try {
445
879
  if (oldParentId !== newParentId) {
446
880
  await request({
@@ -462,7 +896,7 @@ function MenuTreeDialog({
462
896
  );
463
897
 
464
898
  toast.success(t('treeOrderSaved'));
465
- onSaved();
899
+ await onSaved();
466
900
  } catch {
467
901
  toast.error(t('treeOrderError'));
468
902
  setTree(buildTree(menus as TreeNode[]));
@@ -481,120 +915,267 @@ function MenuTreeDialog({
481
915
  const isBefore = isTarget && dropTarget?.position === 'before';
482
916
  const isAfter = isTarget && dropTarget?.position === 'after';
483
917
  const isInside = isTarget && dropTarget?.position === 'inside';
918
+ const hasChildren = node.children.length > 0;
919
+ const isExpanded = searchQuery.trim() ? true : expandedIds.has(node.id);
920
+ const roleCount = getMenuRoleCount(node);
921
+ const visual = getMenuVisual(node);
922
+ const NodeIcon = visual.Icon;
484
923
 
485
924
  return (
486
- <div key={node.id}>
487
- {isBefore && <div className="h-0.5 bg-primary rounded mx-2 my-0.5" />}
488
- <div
489
- draggable
490
- onDragStart={(e) => {
491
- e.dataTransfer.effectAllowed = 'move';
492
- setDragging({ id: node.id, parentId });
493
- }}
494
- onDragEnd={() => {
495
- setDragging(null);
496
- setDropTarget(null);
497
- }}
498
- onDragOver={(e) => {
499
- e.preventDefault();
500
- e.stopPropagation();
501
- if (!dragging || dragging.id === node.id) return;
502
- if (isDescendantOf(tree, dragging.id, node.id)) return;
503
-
504
- const rect = (
505
- e.currentTarget as HTMLDivElement
506
- ).getBoundingClientRect();
507
- const ratio = (e.clientY - rect.top) / rect.height;
508
-
509
- let pos: DropPosition;
510
- if (ratio < 0.25) pos = 'before';
511
- else if (ratio > 0.75) pos = 'after';
512
- else pos = 'inside';
513
-
514
- setDropTarget({ id: node.id, parentId, position: pos });
515
- }}
516
- onDragLeave={(e) => {
517
- if (!e.currentTarget.contains(e.relatedTarget as Node)) {
518
- setDropTarget((prev) => (prev?.id === node.id ? null : prev));
519
- }
520
- }}
521
- onDrop={(e) => {
522
- e.preventDefault();
523
- e.stopPropagation();
524
- handleDrop(dropTarget);
525
- }}
526
- style={{ paddingLeft: `${depth * 20}px` }}
527
- className={[
528
- 'flex items-center gap-2 px-3 py-2 rounded-md cursor-grab select-none transition-all',
529
- isDraggingThis ? 'opacity-40' : 'opacity-100',
530
- isInside ? 'ring-2 ring-primary bg-primary/10' : 'hover:bg-muted',
531
- ].join(' ')}
532
- >
533
- <span className="text-muted-foreground cursor-grab" title="Arrastar">
534
- <svg width="14" height="14" viewBox="0 0 14 14" fill="currentColor">
535
- <circle cx="4" cy="3" r="1.2" />
536
- <circle cx="10" cy="3" r="1.2" />
537
- <circle cx="4" cy="7" r="1.2" />
538
- <circle cx="10" cy="7" r="1.2" />
539
- <circle cx="4" cy="11" r="1.2" />
540
- <circle cx="10" cy="11" r="1.2" />
541
- </svg>
542
- </span>
543
- <Menu className="h-4 w-4 text-muted-foreground shrink-0" />
544
- <div className="flex-1 min-w-0">
545
- <span className="text-sm font-medium">{getDisplayName(node)}</span>
546
- <span className="text-xs text-muted-foreground ml-2">
547
- {node.url}
548
- </span>
549
- </div>
550
- {node.children.length > 0 && (
551
- <span className="text-xs text-muted-foreground shrink-0">
552
- {node.children.length}{' '}
553
- {node.children.length === 1 ? t('treeChild') : t('treeChildren')}
554
- </span>
555
- )}
556
- </div>
557
- {isAfter && <div className="h-0.5 bg-primary rounded mx-2 my-0.5" />}
558
- {node.children.length > 0 && (
559
- <div className="ml-2 border-l border-border/50 pl-1 mt-0.5 mb-0.5 space-y-0.5">
925
+ <div key={node.id} className="space-y-1">
926
+ {isBefore ? (
927
+ <div className="mx-2 my-0.5 h-0.5 rounded bg-primary" />
928
+ ) : null}
929
+
930
+ <ContextMenu>
931
+ <ContextMenuTrigger asChild>
932
+ <div
933
+ draggable
934
+ onClick={() => {
935
+ void onSelect(node.id);
936
+ }}
937
+ onDragStart={(event) => {
938
+ event.dataTransfer.effectAllowed = 'move';
939
+ setDragging({ id: node.id, parentId });
940
+ }}
941
+ onDragEnd={() => {
942
+ setDragging(null);
943
+ setDropTarget(null);
944
+ }}
945
+ onDragOver={(event) => {
946
+ event.preventDefault();
947
+ event.stopPropagation();
948
+
949
+ if (!dragging || dragging.id === node.id) {
950
+ return;
951
+ }
952
+
953
+ if (isDescendantOf(tree, dragging.id, node.id)) {
954
+ return;
955
+ }
956
+
957
+ const rect = (
958
+ event.currentTarget as HTMLDivElement
959
+ ).getBoundingClientRect();
960
+ const ratio = (event.clientY - rect.top) / rect.height;
961
+
962
+ let position: DropPosition = 'inside';
963
+ if (ratio < 0.25) {
964
+ position = 'before';
965
+ } else if (ratio > 0.75) {
966
+ position = 'after';
967
+ }
968
+
969
+ setDropTarget({ id: node.id, parentId, position });
970
+ }}
971
+ onDragLeave={(event) => {
972
+ if (
973
+ !event.currentTarget.contains(event.relatedTarget as Node)
974
+ ) {
975
+ setDropTarget((previous) =>
976
+ previous?.id === node.id ? null : previous
977
+ );
978
+ }
979
+ }}
980
+ onDrop={(event) => {
981
+ event.preventDefault();
982
+ event.stopPropagation();
983
+ void handleDrop(dropTarget);
984
+ }}
985
+ style={{ paddingLeft: `${depth * 14}px` }}
986
+ className={cn(
987
+ 'group relative mr-1 flex cursor-pointer items-center gap-2 rounded-xl border px-2 py-1.5 transition-all duration-150 select-none active:scale-[0.99]',
988
+ isDraggingThis ? 'opacity-40' : 'opacity-100',
989
+ selectedId === node.id
990
+ ? 'border-primary/70 bg-linear-to-r from-primary/10 via-primary/5 to-background shadow-[0_10px_24px_-18px_hsl(var(--primary))] ring-1 ring-primary/15'
991
+ : 'border-transparent hover:border-primary/30 hover:bg-muted/60 hover:shadow-sm',
992
+ isInside ? 'ring-2 ring-primary bg-primary/10' : ''
993
+ )}
994
+ >
995
+ {selectedId === node.id ? (
996
+ <span className="absolute inset-y-1 left-1 w-1 rounded-full bg-primary/70" />
997
+ ) : null}
998
+
999
+ <button
1000
+ type="button"
1001
+ className="flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground transition hover:bg-muted"
1002
+ onClick={(event) => {
1003
+ event.stopPropagation();
1004
+ if (hasChildren) {
1005
+ toggleExpanded(node.id);
1006
+ }
1007
+ }}
1008
+ aria-label={hasChildren ? t('buttonViewTree') : t('menus')}
1009
+ >
1010
+ {hasChildren ? (
1011
+ isExpanded ? (
1012
+ <ChevronDown className="h-4 w-4" />
1013
+ ) : (
1014
+ <ChevronRight className="h-4 w-4" />
1015
+ )
1016
+ ) : (
1017
+ <span className="h-4 w-4" />
1018
+ )}
1019
+ </button>
1020
+
1021
+ <span
1022
+ className="cursor-grab text-muted-foreground"
1023
+ title="Arrastar"
1024
+ >
1025
+ <GripVertical className="h-4 w-4" />
1026
+ </span>
1027
+
1028
+ <div
1029
+ className={cn(
1030
+ 'flex h-8 w-8 shrink-0 items-center justify-center rounded-md transition-colors',
1031
+ selectedId === node.id
1032
+ ? `${visual.iconWrapperClassName} ring-1 ring-primary/20`
1033
+ : visual.iconWrapperClassName
1034
+ )}
1035
+ >
1036
+ <NodeIcon
1037
+ className={cn(
1038
+ 'h-4 w-4',
1039
+ selectedId === node.id
1040
+ ? 'text-primary'
1041
+ : visual.iconClassName
1042
+ )}
1043
+ />
1044
+ </div>
1045
+
1046
+ <div className="min-w-0 flex-1">
1047
+ <div className="flex flex-wrap items-center gap-1.5">
1048
+ <span className="truncate text-sm font-medium">
1049
+ {getDisplayName(node)}
1050
+ </span>
1051
+ <TooltipBadge
1052
+ tooltip={
1053
+ node.menu_id == null ? t('rootMenuLabel') : t('subMenu')
1054
+ }
1055
+ variant={node.menu_id == null ? 'default' : 'secondary'}
1056
+ className="rounded-full px-2 py-0 text-[10px]"
1057
+ >
1058
+ {node.menu_id == null ? t('rootMenuLabel') : t('subMenu')}
1059
+ </TooltipBadge>
1060
+ <TooltipBadge
1061
+ tooltip={`${t('tabRoles')}: ${roleCount}`}
1062
+ variant="outline"
1063
+ className={cn(
1064
+ 'rounded-full px-1.5 py-0 text-[10px]',
1065
+ roleCount > 0
1066
+ ? 'border-primary/30 bg-primary/5 text-primary'
1067
+ : 'text-muted-foreground'
1068
+ )}
1069
+ >
1070
+ <ShieldCheck className="mr-1 h-3 w-3" />
1071
+ {roleCount}
1072
+ </TooltipBadge>
1073
+ </div>
1074
+
1075
+ <div className="mt-0.5 flex items-center gap-2 text-xs text-muted-foreground">
1076
+ <span className="truncate">{node.url || '/'}</span>
1077
+ {node.order != null ? <span>#{node.order}</span> : null}
1078
+ </div>
1079
+ </div>
1080
+ </div>
1081
+ </ContextMenuTrigger>
1082
+
1083
+ <ContextMenuContent className="w-56">
1084
+ <ContextMenuItem onClick={() => void onSelect(node.id)}>
1085
+ <Menu className="h-4 w-4" />
1086
+ {t('buttonEditMenu')}
1087
+ </ContextMenuItem>
1088
+ <ContextMenuItem onClick={() => void onAddSubmenu(node.id)}>
1089
+ <FolderPlus className="h-4 w-4" />
1090
+ {t('addSubmenu')}
1091
+ </ContextMenuItem>
1092
+ <ContextMenuItem onClick={() => void onDuplicate(node.id)}>
1093
+ <Copy className="h-4 w-4" />
1094
+ {t('duplicateMenu')}
1095
+ </ContextMenuItem>
1096
+ <ContextMenuItem onClick={() => void onMoveToRoot(node.id)}>
1097
+ <House className="h-4 w-4" />
1098
+ {t('moveToRoot')}
1099
+ </ContextMenuItem>
1100
+ <ContextMenuSeparator />
1101
+ <ContextMenuItem
1102
+ variant="destructive"
1103
+ onClick={() => void onDelete(node.id)}
1104
+ >
1105
+ <Trash2 className="h-4 w-4" />
1106
+ {t('buttonDeleteMenu')}
1107
+ </ContextMenuItem>
1108
+ </ContextMenuContent>
1109
+ </ContextMenu>
1110
+
1111
+ {isAfter ? (
1112
+ <div className="mx-2 my-0.5 h-0.5 rounded bg-primary" />
1113
+ ) : null}
1114
+
1115
+ {hasChildren && isExpanded ? (
1116
+ <div className="mb-0.5 ml-3.5 space-y-1 border-l border-dashed border-primary/20 pl-1.5">
560
1117
  {node.children.map((child) =>
561
1118
  renderNode(child, node.id, depth + 1)
562
1119
  )}
563
1120
  </div>
564
- )}
1121
+ ) : null}
565
1122
  </div>
566
1123
  );
567
1124
  };
568
1125
 
569
1126
  return (
570
- <Dialog
571
- open={open}
572
- onOpenChange={(v) => {
573
- if (!v) onClose();
574
- }}
575
- >
576
- <DialogContent className="sm:max-w-2xl max-h-[85vh] flex flex-col">
577
- <DialogHeader>
578
- <DialogTitle className="flex items-center gap-2">
579
- {t('treeDialogTitle')}
580
- {saving && (
581
- <Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
582
- )}
583
- </DialogTitle>
584
- <DialogDescription>{t('treeDialogDescription')}</DialogDescription>
585
- </DialogHeader>
586
-
587
- <div className="flex-1 overflow-y-auto border rounded-lg p-2 space-y-0.5 min-h-0">
588
- {tree.length === 0 ? (
589
- <div className="flex items-center justify-center py-12 text-muted-foreground text-sm">
590
- {t('noMenusFound')}
1127
+ <div className="flex h-full min-h-110 flex-col">
1128
+ <div className="flex items-start justify-between gap-2">
1129
+ <div>
1130
+ <h3 className="flex items-center gap-2 text-sm font-semibold">
1131
+ <GitBranch className="h-4 w-4" />
1132
+ {t('treeWorkspaceTitle')}
1133
+ </h3>
1134
+ <p className="mt-0.5 text-xs text-muted-foreground">
1135
+ {t('treeWorkspaceDescription')}
1136
+ </p>
1137
+ <div className="mt-2 flex flex-wrap gap-1.5">
1138
+ <TooltipBadge
1139
+ tooltip={`${t('menus')}: ${menus.length}`}
1140
+ variant="outline"
1141
+ className="rounded-full px-2 py-0.5"
1142
+ >
1143
+ {menus.length} {t('menus')}
1144
+ </TooltipBadge>
1145
+ <TooltipBadge
1146
+ tooltip={`${t('searchResults')}: ${filteredTree.length}`}
1147
+ variant="secondary"
1148
+ className="rounded-full px-2 py-0.5"
1149
+ >
1150
+ {filteredTree.length} {t('searchResults')}
1151
+ </TooltipBadge>
1152
+ </div>
1153
+ </div>
1154
+
1155
+ {saving ? (
1156
+ <Badge variant="outline" className="gap-1 rounded-full px-2 py-0.5">
1157
+ <Loader2 className="h-3.5 w-3.5 animate-spin" />
1158
+ {t('saveChanges')}
1159
+ </Badge>
1160
+ ) : null}
1161
+ </div>
1162
+
1163
+ <div className="mt-1.5 rounded-lg border border-dashed bg-muted/20 p-2 text-[11px] leading-4 text-muted-foreground">
1164
+ {t('treeDialogDescription')}
1165
+ </div>
1166
+
1167
+ <ScrollArea className="mt-2 flex-1 pr-3">
1168
+ <div className="space-y-1.5 pr-2">
1169
+ {filteredTree.length === 0 ? (
1170
+ <div className="flex min-h-55 items-center justify-center rounded-xl border border-dashed text-sm text-muted-foreground">
1171
+ {t('treeEmptySearch')}
591
1172
  </div>
592
1173
  ) : (
593
- tree.map((node) => renderNode(node, null, 0))
1174
+ filteredTree.map((node) => renderNode(node, null, 0))
594
1175
  )}
595
1176
  </div>
596
- </DialogContent>
597
- </Dialog>
1177
+ </ScrollArea>
1178
+ </div>
598
1179
  );
599
1180
  }
600
1181
 
@@ -613,6 +1194,7 @@ type MenuItem = {
613
1194
  name?: string;
614
1195
  locale?: { code: string };
615
1196
  }>;
1197
+ _count?: { role_menu?: number };
616
1198
  name?: string;
617
1199
  locale?: Record<string, MenuLocale>;
618
1200
  };
@@ -621,6 +1203,63 @@ type MenuStats = {
621
1203
  total: number;
622
1204
  };
623
1205
 
1206
+ type LocaleOption = {
1207
+ code: string;
1208
+ name: string;
1209
+ };
1210
+
1211
+ type RequestError = {
1212
+ response?: {
1213
+ data?: {
1214
+ message?: string | string[];
1215
+ error?: string;
1216
+ };
1217
+ };
1218
+ message?: string;
1219
+ };
1220
+
1221
+ function getRequestErrorMessage(error: unknown, fallback: string) {
1222
+ if (error && typeof error === 'object') {
1223
+ const requestError = error as RequestError;
1224
+ const message = requestError.response?.data?.message;
1225
+
1226
+ if (Array.isArray(message)) {
1227
+ return message.join(', ');
1228
+ }
1229
+
1230
+ return (
1231
+ message ||
1232
+ requestError.response?.data?.error ||
1233
+ requestError.message ||
1234
+ fallback
1235
+ );
1236
+ }
1237
+
1238
+ return fallback;
1239
+ }
1240
+
1241
+ type TooltipBadgeProps = React.ComponentProps<typeof Badge> & {
1242
+ tooltip: React.ReactNode;
1243
+ };
1244
+
1245
+ function TooltipBadge({
1246
+ tooltip,
1247
+ className,
1248
+ children,
1249
+ ...props
1250
+ }: TooltipBadgeProps) {
1251
+ return (
1252
+ <Tooltip>
1253
+ <TooltipTrigger asChild>
1254
+ <Badge {...props} className={cn('cursor-help', className)}>
1255
+ {children}
1256
+ </Badge>
1257
+ </TooltipTrigger>
1258
+ <TooltipContent sideOffset={6}>{tooltip}</TooltipContent>
1259
+ </Tooltip>
1260
+ );
1261
+ }
1262
+
624
1263
  const NO_PARENT = '__none__';
625
1264
 
626
1265
  export default function MenuPage() {
@@ -635,15 +1274,16 @@ export default function MenuPage() {
635
1274
  const [editFormError, setEditFormError] = useState<string | null>(null);
636
1275
  const [openDeleteModal, setOpenDeleteModal] = useState(false);
637
1276
  const [selectedLocale, setSelectedLocale] = useState(currentLocaleCode);
1277
+ const [isRefreshingMenu, setIsRefreshingMenu] = useState(false);
638
1278
 
639
1279
  const [page, setPage] = useState(1);
640
1280
  const [pageSize, setPageSize] = useState(12);
641
1281
 
642
- const { data: allMenus } = useQuery<MenuItem[]>({
1282
+ const { data: allMenus, refetch: refetchAllMenus } = useQuery<MenuItem[]>({
643
1283
  queryKey: ['menus-all', currentLocaleCode],
644
1284
  queryFn: async () => {
645
1285
  const response = await request<MenuItem[]>({
646
- url: `/menu/all`,
1286
+ url: '/menu/all',
647
1287
  method: 'GET',
648
1288
  });
649
1289
  return response.data;
@@ -655,7 +1295,7 @@ export default function MenuPage() {
655
1295
  queryKey: ['menus-stats'],
656
1296
  queryFn: async () => {
657
1297
  const response = await request<MenuStats>({
658
- url: `/menu/stats`,
1298
+ url: '/menu/stats',
659
1299
  method: 'GET',
660
1300
  });
661
1301
  return response.data;
@@ -672,12 +1312,15 @@ export default function MenuPage() {
672
1312
  const params = new URLSearchParams();
673
1313
  params.set('page', String(page));
674
1314
  params.set('pageSize', String(pageSize));
675
- if (searchQuery) params.set('search', searchQuery);
1315
+ if (searchQuery) {
1316
+ params.set('search', searchQuery);
1317
+ }
676
1318
 
677
1319
  const response = await request<PaginatedResponse<MenuItem>>({
678
1320
  url: `/menu?${params.toString()}`,
679
1321
  method: 'GET',
680
1322
  });
1323
+
681
1324
  return response.data;
682
1325
  },
683
1326
  });
@@ -724,41 +1367,131 @@ export default function MenuPage() {
724
1367
  },
725
1368
  });
726
1369
 
1370
+ const getMenuDisplayName = useCallback((menu: MenuItem | TreeNode) => {
1371
+ return menu.name || menu.menu_locale?.[0]?.name || menu.slug;
1372
+ }, []);
1373
+
1374
+ const editingMenuId = editingMenu?.id ?? null;
1375
+
1376
+ const handleEdit = useCallback(
1377
+ async (menu: Pick<MenuItem, 'id'>) => {
1378
+ setEditFormError(null);
1379
+ setIsRefreshingMenu(true);
1380
+
1381
+ try {
1382
+ const response = await request<MenuItem>({
1383
+ url: `/menu/${menu.id}`,
1384
+ method: 'GET',
1385
+ });
1386
+
1387
+ const fullMenu = response.data;
1388
+ const localeData: Record<string, MenuLocale> = {};
1389
+
1390
+ if (fullMenu.menu_locale && Array.isArray(fullMenu.menu_locale)) {
1391
+ fullMenu.menu_locale.forEach((localeItem) => {
1392
+ const localeCode = localeItem.locale?.code;
1393
+ if (localeCode) {
1394
+ localeData[localeCode] = { name: localeItem.name || '' };
1395
+ }
1396
+ });
1397
+ }
1398
+
1399
+ (locales as LocaleOption[] | undefined)?.forEach((locale) => {
1400
+ if (!localeData[locale.code]) {
1401
+ localeData[locale.code] = { name: '' };
1402
+ }
1403
+ });
1404
+
1405
+ setEditingMenu({ ...fullMenu, locale: localeData });
1406
+ } catch (error) {
1407
+ console.error('Error fetching menu:', error);
1408
+ toast.error(t('serverError'));
1409
+ } finally {
1410
+ setIsRefreshingMenu(false);
1411
+ }
1412
+ },
1413
+ [locales, request, t]
1414
+ );
1415
+
727
1416
  useEffect(() => {
728
- if (editingMenu) {
729
- const localeData = editingMenu.locale?.[selectedLocale];
730
- editForm.setValue('name', localeData?.name ?? '');
1417
+ if (editingMenuId == null) {
1418
+ return;
731
1419
  }
732
- }, [selectedLocale, editingMenu?.id]);
1420
+
1421
+ setSelectedLocale(currentLocaleCode);
1422
+ }, [currentLocaleCode, editingMenuId]);
733
1423
 
734
1424
  useEffect(() => {
735
- if (editingMenu) {
736
- const localeData = editingMenu.locale?.[currentLocaleCode];
737
- setSelectedLocale(currentLocaleCode);
738
- editForm.reset({
739
- slug: editingMenu.slug || '',
740
- url: editingMenu.url || '',
741
- icon: editingMenu.icon || '',
742
- order: editingMenu.order ?? undefined,
743
- menu_id: editingMenu.menu_id ? String(editingMenu.menu_id) : NO_PARENT,
744
- name: localeData?.name || editingMenu.name || '',
745
- });
1425
+ if (!editingMenu) {
1426
+ return;
746
1427
  }
747
- }, [editingMenu?.id]);
748
1428
 
749
- const onSubmit = async (values: z.infer<typeof addMenuSchema>) => {
750
- try {
751
- const localePayload: Record<string, string> = {};
752
- if (values.name) {
753
- localePayload[currentLocaleCode] = values.name;
754
- }
1429
+ const localeData = editingMenu.locale?.[selectedLocale];
1430
+ editForm.reset({
1431
+ slug: editingMenu.slug || '',
1432
+ url: editingMenu.url || '',
1433
+ icon: editingMenu.icon || '',
1434
+ order: editingMenu.order ?? undefined,
1435
+ menu_id: editingMenu.menu_id ? String(editingMenu.menu_id) : NO_PARENT,
1436
+ name: localeData?.name || editingMenu.name || '',
1437
+ });
1438
+ }, [editForm, editingMenu, selectedLocale]);
755
1439
 
756
- await request({
757
- url: '/menu',
758
- method: 'POST',
759
- data: {
760
- slug: values.slug,
761
- url: values.url,
1440
+ useEffect(() => {
1441
+ if (!allMenus || allMenus.length === 0) {
1442
+ setEditingMenu(null);
1443
+ return;
1444
+ }
1445
+
1446
+ if (editingMenu && allMenus.some((menu) => menu.id === editingMenu.id)) {
1447
+ return;
1448
+ }
1449
+
1450
+ const firstMenu = allMenus[0];
1451
+ if (!firstMenu) {
1452
+ return;
1453
+ }
1454
+
1455
+ void handleEdit({ id: firstMenu.id });
1456
+ }, [allMenus, editingMenu, handleEdit]);
1457
+
1458
+ const openCreateMenu = (parentId?: number | null) => {
1459
+ setFormError(null);
1460
+ form.reset({
1461
+ slug: '',
1462
+ url: '',
1463
+ name: '',
1464
+ icon: '',
1465
+ order: undefined,
1466
+ menu_id: parentId ? String(parentId) : NO_PARENT,
1467
+ });
1468
+ setIsDialogOpen(true);
1469
+ };
1470
+
1471
+ const refreshWorkspace = useCallback(
1472
+ async (menuIdToReload?: number | null) => {
1473
+ await Promise.all([refetch(), refetchAllMenus()]);
1474
+
1475
+ if (menuIdToReload) {
1476
+ await handleEdit({ id: menuIdToReload });
1477
+ }
1478
+ },
1479
+ [handleEdit, refetch, refetchAllMenus]
1480
+ );
1481
+
1482
+ const onSubmit = async (values: z.infer<typeof addMenuSchema>) => {
1483
+ try {
1484
+ const localePayload: Record<string, string> = {};
1485
+ if (values.name) {
1486
+ localePayload[currentLocaleCode] = values.name;
1487
+ }
1488
+
1489
+ const response = await request<MenuItem>({
1490
+ url: '/menu',
1491
+ method: 'POST',
1492
+ data: {
1493
+ slug: values.slug,
1494
+ url: values.url,
762
1495
  icon: values.icon || undefined,
763
1496
  order: values.order || undefined,
764
1497
  menu_id:
@@ -779,64 +1512,31 @@ export default function MenuPage() {
779
1512
  order: undefined,
780
1513
  menu_id: NO_PARENT,
781
1514
  });
782
- refetch();
1515
+
783
1516
  setIsDialogOpen(false);
784
1517
  setFormError(null);
785
1518
  toast.success(t('menuCreatedSuccess'));
786
- } catch (err: any) {
787
- const msg =
788
- err?.response?.data?.message ||
789
- err?.response?.data?.error ||
790
- err?.message ||
791
- t('serverError');
792
- setFormError(String(msg));
793
- }
794
- };
795
1519
 
796
- const handleEdit = async (menu: MenuItem) => {
797
- setEditFormError(null);
798
-
799
- try {
800
- const response = await request<MenuItem>({
801
- url: `/menu/${menu.id}`,
802
- method: 'GET',
803
- });
804
-
805
- const fullMenu = response.data;
806
- const localeData: Record<string, MenuLocale> = {};
807
-
808
- if (fullMenu.menu_locale && Array.isArray(fullMenu.menu_locale)) {
809
- fullMenu.menu_locale.forEach((ml: any) => {
810
- const localeCode = ml.locale?.code;
811
- if (localeCode) {
812
- localeData[localeCode] = { name: ml.name || '' };
813
- }
814
- });
815
- }
816
-
817
- locales?.forEach((locale: any) => {
818
- if (!localeData[locale.code]) {
819
- localeData[locale.code] = { name: '' };
820
- }
821
- });
822
-
823
- setEditingMenu({ ...fullMenu, locale: localeData });
824
- } catch (err) {
825
- console.error('Error fetching menu:', err);
826
- toast.error(t('serverError'));
1520
+ await refreshWorkspace(response.data?.id ?? null);
1521
+ } catch (error: unknown) {
1522
+ setFormError(getRequestErrorMessage(error, t('serverError')));
827
1523
  }
828
1524
  };
829
1525
 
830
1526
  const onEditSubmit = async (values: z.infer<typeof editMenuSchema>) => {
831
- if (!editingMenu) return;
1527
+ if (!editingMenu) {
1528
+ return;
1529
+ }
832
1530
 
833
1531
  try {
834
1532
  const localePayload: Record<string, string> = {};
1533
+
835
1534
  if (editingMenu.locale) {
836
1535
  Object.entries(editingMenu.locale).forEach(([code, data]) => {
837
1536
  localePayload[code] = data.name;
838
1537
  });
839
1538
  }
1539
+
840
1540
  localePayload[selectedLocale] = values.name || '';
841
1541
 
842
1542
  await request({
@@ -857,43 +1557,564 @@ export default function MenuPage() {
857
1557
 
858
1558
  toast.success(t('menuUpdatedSuccess'));
859
1559
  setEditFormError(null);
860
- await refetch();
861
- setEditingMenu(null);
862
- } catch (err: any) {
863
- const msg =
864
- err?.response?.data?.message ||
865
- err?.response?.data?.error ||
866
- err?.message ||
867
- t('serverError');
868
- setEditFormError(String(msg));
1560
+ await refreshWorkspace(editingMenu.id);
1561
+ } catch (error: unknown) {
1562
+ setEditFormError(getRequestErrorMessage(error, t('serverError')));
869
1563
  }
870
1564
  };
871
1565
 
1566
+ const handleDuplicateMenu = async (menuId: number) => {
1567
+ try {
1568
+ const response = await request<MenuItem>({
1569
+ url: `/menu/${menuId}`,
1570
+ method: 'GET',
1571
+ });
1572
+
1573
+ const fullMenu = response.data;
1574
+ const localePayload: Record<string, string> = {};
1575
+
1576
+ if (Array.isArray(fullMenu.menu_locale)) {
1577
+ fullMenu.menu_locale.forEach((localeItem) => {
1578
+ const localeCode = localeItem.locale?.code;
1579
+ if (localeCode && localeItem.name) {
1580
+ localePayload[localeCode] = `${localeItem.name} Copy`;
1581
+ }
1582
+ });
1583
+ }
1584
+
1585
+ const slugSuffix = `copy-${Date.now().toString().slice(-4)}`;
1586
+ const created = await request<MenuItem>({
1587
+ url: '/menu',
1588
+ method: 'POST',
1589
+ data: {
1590
+ slug: `${fullMenu.slug}-${slugSuffix}`.slice(0, 120),
1591
+ url: fullMenu.url || '',
1592
+ icon: fullMenu.icon || undefined,
1593
+ order: fullMenu.order || undefined,
1594
+ menu_id: fullMenu.menu_id ?? undefined,
1595
+ ...(Object.keys(localePayload).length > 0
1596
+ ? { locale: localePayload }
1597
+ : {}),
1598
+ },
1599
+ });
1600
+
1601
+ toast.success(t('duplicateSuccess'));
1602
+ await refreshWorkspace(created.data?.id ?? null);
1603
+ } catch {
1604
+ toast.error(t('duplicateError'));
1605
+ }
1606
+ };
1607
+
1608
+ const handleMoveToRoot = async (menuId: number) => {
1609
+ try {
1610
+ await request({
1611
+ url: `/menu/${menuId}`,
1612
+ method: 'PATCH',
1613
+ data: { menu_id: null },
1614
+ });
1615
+
1616
+ toast.success(t('moveToRootSuccess'));
1617
+ await refreshWorkspace(menuId);
1618
+ } catch {
1619
+ toast.error(t('serverError'));
1620
+ }
1621
+ };
1622
+
1623
+ const requestDeleteMenu = async (menuId: number) => {
1624
+ if (editingMenu?.id !== menuId) {
1625
+ await handleEdit({ id: menuId });
1626
+ }
1627
+
1628
+ setOpenDeleteModal(true);
1629
+ };
1630
+
872
1631
  const onDelete = async () => {
1632
+ if (!editingMenu) {
1633
+ return;
1634
+ }
1635
+
873
1636
  try {
874
1637
  await request({
875
- url: `/menu`,
1638
+ url: '/menu',
876
1639
  method: 'DELETE',
877
- data: { ids: [Number(editingMenu?.id)] },
1640
+ data: { ids: [Number(editingMenu.id)] },
878
1641
  });
879
- refetch();
1642
+
880
1643
  setOpenDeleteModal(false);
881
1644
  setEditingMenu(null);
882
1645
  setEditFormError(null);
883
1646
  toast.success(t('menuDeletedSuccess'));
884
- } catch (err: any) {
885
- const msg =
886
- err?.response?.data?.message ||
887
- err?.response?.data?.error ||
888
- err?.message ||
889
- t('serverError');
890
- setEditFormError(String(msg));
1647
+
1648
+ await Promise.all([refetch(), refetchAllMenus()]);
1649
+ } catch (error: unknown) {
1650
+ setEditFormError(getRequestErrorMessage(error, t('serverError')));
891
1651
  }
892
1652
  };
893
1653
 
894
- const getMenuDisplayName = (menu: MenuItem) => menu.name || menu.slug;
895
- const parentMenuOptions = (allMenus ?? []).filter(
896
- (m) => m.id !== editingMenu?.id
1654
+ const descendantIds = useMemo(
1655
+ () => (editingMenu ? getDescendantIds(allMenus ?? [], editingMenu.id) : []),
1656
+ [allMenus, editingMenu]
1657
+ );
1658
+
1659
+ const parentMenuOptions = useMemo(
1660
+ () =>
1661
+ (allMenus ?? []).filter(
1662
+ (menu) =>
1663
+ menu.id !== editingMenu?.id && !descendantIds.includes(menu.id)
1664
+ ),
1665
+ [allMenus, descendantIds, editingMenu]
1666
+ );
1667
+
1668
+ const rootMenusCount = useMemo(
1669
+ () => (allMenus ?? []).filter((menu) => menu.menu_id == null).length,
1670
+ [allMenus]
1671
+ );
1672
+
1673
+ const selectedChildrenCount = useMemo(
1674
+ () =>
1675
+ editingMenu
1676
+ ? (allMenus ?? []).filter((menu) => menu.menu_id === editingMenu.id)
1677
+ .length
1678
+ : 0,
1679
+ [allMenus, editingMenu]
1680
+ );
1681
+
1682
+ const subMenuCount = useMemo(
1683
+ () => Math.max((allMenus?.length ?? 0) - rootMenusCount, 0),
1684
+ [allMenus, rootMenusCount]
1685
+ );
1686
+
1687
+ const highlightKpiTitle = searchQuery.trim()
1688
+ ? t('searchResults')
1689
+ : t('childrenCount');
1690
+
1691
+ const highlightKpiValue = searchQuery.trim()
1692
+ ? String(menusResponse?.total ?? 0)
1693
+ : String(selectedChildrenCount);
1694
+
1695
+ const selectedParentLabel = useMemo(() => {
1696
+ if (!editingMenu?.menu_id) {
1697
+ return t('formParentMenuNone');
1698
+ }
1699
+
1700
+ const parent = (allMenus ?? []).find(
1701
+ (menu) => menu.id === editingMenu.menu_id
1702
+ );
1703
+ return parent ? getMenuDisplayName(parent) : t('formParentMenuNone');
1704
+ }, [allMenus, editingMenu, getMenuDisplayName, t]);
1705
+
1706
+ const selectedRoleCount = useMemo(() => {
1707
+ if (!editingMenu) {
1708
+ return 0;
1709
+ }
1710
+
1711
+ const currentMenu = (allMenus ?? []).find(
1712
+ (menu) => menu.id === editingMenu.id
1713
+ );
1714
+ return getMenuRoleCount(currentMenu ?? editingMenu);
1715
+ }, [allMenus, editingMenu]);
1716
+
1717
+ const selectedMenuVisual = editingMenu ? getMenuVisual(editingMenu) : null;
1718
+
1719
+ const detailPanel = editingMenu ? (
1720
+ <div className="space-y-2.5">
1721
+ <Card className="rounded-xl border-border/60 bg-linear-to-br from-primary/4 via-background to-background shadow-sm">
1722
+ <CardHeader className="gap-2.5 p-3.5">
1723
+ <div className="flex flex-col gap-3 xl:flex-row xl:items-start xl:justify-between">
1724
+ <div className="flex min-w-0 items-start gap-3">
1725
+ <div
1726
+ className={cn(
1727
+ 'flex h-12 w-12 shrink-0 items-center justify-center rounded-2xl ring-1 ring-border/60',
1728
+ selectedMenuVisual?.iconWrapperClassName ?? 'bg-primary/10'
1729
+ )}
1730
+ >
1731
+ {selectedMenuVisual ? (
1732
+ <selectedMenuVisual.Icon
1733
+ className={cn('h-6 w-6', selectedMenuVisual.iconClassName)}
1734
+ />
1735
+ ) : (
1736
+ <Menu className="h-6 w-6 text-primary" />
1737
+ )}
1738
+ </div>
1739
+
1740
+ <div className="min-w-0">
1741
+ <CardTitle className="flex flex-wrap items-center gap-2 text-base">
1742
+ {getMenuDisplayName(editingMenu)}
1743
+ {isRefreshingMenu ? (
1744
+ <Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
1745
+ ) : null}
1746
+ <TooltipBadge
1747
+ tooltip={`${t('tabRoles')}: ${selectedRoleCount}`}
1748
+ variant="outline"
1749
+ className={cn(
1750
+ 'rounded-full px-2 py-0.5 text-[10px]',
1751
+ selectedRoleCount > 0
1752
+ ? 'border-primary/30 bg-primary/5 text-primary'
1753
+ : 'text-muted-foreground'
1754
+ )}
1755
+ >
1756
+ <ShieldCheck className="mr-1 h-3 w-3" />
1757
+ {selectedRoleCount}
1758
+ </TooltipBadge>
1759
+ </CardTitle>
1760
+ <CardDescription className="mt-1 flex items-center gap-1 text-xs">
1761
+ <Link className="h-3 w-3" />
1762
+ <span className="truncate">{editingMenu.url || '/'}</span>
1763
+ </CardDescription>
1764
+ <div className="mt-2 flex flex-wrap gap-1.5">
1765
+ <TooltipBadge tooltip={editingMenu.slug} variant="secondary">
1766
+ {editingMenu.slug}
1767
+ </TooltipBadge>
1768
+ <TooltipBadge
1769
+ tooltip={
1770
+ editingMenu.menu_id == null
1771
+ ? t('rootMenuLabel')
1772
+ : t('subMenu')
1773
+ }
1774
+ variant="outline"
1775
+ >
1776
+ {editingMenu.menu_id == null
1777
+ ? t('rootMenuLabel')
1778
+ : t('subMenu')}
1779
+ </TooltipBadge>
1780
+ {editingMenu.order != null ? (
1781
+ <TooltipBadge
1782
+ tooltip={`${t('editOrderLabel')}: #${editingMenu.order}`}
1783
+ variant="outline"
1784
+ >
1785
+ #{editingMenu.order}
1786
+ </TooltipBadge>
1787
+ ) : null}
1788
+ </div>
1789
+ </div>
1790
+ </div>
1791
+
1792
+ <div className="flex flex-wrap items-center gap-1.5 xl:justify-end">
1793
+ <Select
1794
+ value={selectedLocale}
1795
+ onValueChange={(value) => {
1796
+ const currentName = editForm.getValues('name');
1797
+ setEditingMenu((previous) => {
1798
+ if (!previous) {
1799
+ return previous;
1800
+ }
1801
+
1802
+ return {
1803
+ ...previous,
1804
+ locale: {
1805
+ ...previous.locale,
1806
+ [selectedLocale]: { name: currentName ?? '' },
1807
+ },
1808
+ };
1809
+ });
1810
+ setSelectedLocale(value);
1811
+ }}
1812
+ >
1813
+ <SelectTrigger className="h-8 w-full sm:w-36">
1814
+ <SelectValue placeholder={t('selectedLocaleLabel')} />
1815
+ </SelectTrigger>
1816
+ <SelectContent>
1817
+ {((locales as LocaleOption[] | undefined) ?? []).map(
1818
+ (locale) => (
1819
+ <SelectItem key={locale.code} value={locale.code}>
1820
+ {locale.name}
1821
+ </SelectItem>
1822
+ )
1823
+ )}
1824
+ </SelectContent>
1825
+ </Select>
1826
+
1827
+ <Tooltip>
1828
+ <TooltipTrigger asChild>
1829
+ <Button
1830
+ type="button"
1831
+ size="icon"
1832
+ variant="outline"
1833
+ onClick={() => openCreateMenu(editingMenu.id)}
1834
+ className="h-8 w-8 cursor-pointer"
1835
+ aria-label={t('addSubmenu')}
1836
+ >
1837
+ <FolderPlus className="h-4 w-4" />
1838
+ </Button>
1839
+ </TooltipTrigger>
1840
+ <TooltipContent>{t('addSubmenu')}</TooltipContent>
1841
+ </Tooltip>
1842
+
1843
+ <Tooltip>
1844
+ <TooltipTrigger asChild>
1845
+ <Button
1846
+ type="button"
1847
+ size="icon"
1848
+ variant="outline"
1849
+ onClick={() => void handleDuplicateMenu(editingMenu.id)}
1850
+ className="h-8 w-8 cursor-pointer"
1851
+ aria-label={t('duplicateMenu')}
1852
+ >
1853
+ <Copy className="h-4 w-4" />
1854
+ </Button>
1855
+ </TooltipTrigger>
1856
+ <TooltipContent>{t('duplicateMenu')}</TooltipContent>
1857
+ </Tooltip>
1858
+
1859
+ <Tooltip>
1860
+ <TooltipTrigger asChild>
1861
+ <Button
1862
+ type="button"
1863
+ size="icon"
1864
+ variant="destructive"
1865
+ onClick={() => setOpenDeleteModal(true)}
1866
+ className="h-8 w-8 cursor-pointer"
1867
+ aria-label={t('buttonDeleteMenu')}
1868
+ >
1869
+ <Trash2 className="h-4 w-4" />
1870
+ </Button>
1871
+ </TooltipTrigger>
1872
+ <TooltipContent>{t('buttonDeleteMenu')}</TooltipContent>
1873
+ </Tooltip>
1874
+ </div>
1875
+ </div>
1876
+
1877
+ <KpiCardsGrid
1878
+ columns={4}
1879
+ className="gap-2.5"
1880
+ cardClassName="border-border/60 shadow-none"
1881
+ items={[
1882
+ {
1883
+ key: 'selected-parent',
1884
+ title: t('parentMenuSummary'),
1885
+ value: selectedParentLabel,
1886
+ icon: House,
1887
+ layout: 'compact',
1888
+ accentClassName:
1889
+ 'from-emerald-500/20 via-emerald-400/10 to-transparent',
1890
+ iconContainerClassName: 'bg-emerald-50 text-emerald-600',
1891
+ valueClassName: 'mt-2 text-sm font-semibold leading-5',
1892
+ },
1893
+ {
1894
+ key: 'selected-children',
1895
+ title: t('childrenCount'),
1896
+ value: String(selectedChildrenCount),
1897
+ icon: GitBranch,
1898
+ layout: 'compact',
1899
+ accentClassName:
1900
+ 'from-violet-500/20 via-fuchsia-500/10 to-transparent',
1901
+ iconContainerClassName: 'bg-violet-50 text-violet-600',
1902
+ },
1903
+ {
1904
+ key: 'selected-roles',
1905
+ title: t('tabRoles'),
1906
+ value: String(selectedRoleCount),
1907
+ icon: ShieldCheck,
1908
+ layout: 'compact',
1909
+ accentClassName:
1910
+ 'from-sky-500/20 via-cyan-500/10 to-transparent',
1911
+ iconContainerClassName: 'bg-sky-50 text-sky-700',
1912
+ },
1913
+ {
1914
+ key: 'selected-locale',
1915
+ title: t('selectedLocaleLabel'),
1916
+ value: selectedLocale.toUpperCase(),
1917
+ icon: Settings,
1918
+ layout: 'compact',
1919
+ accentClassName:
1920
+ 'from-amber-500/20 via-yellow-500/10 to-transparent',
1921
+ iconContainerClassName: 'bg-amber-50 text-amber-700',
1922
+ valueClassName: 'mt-2 text-sm font-semibold uppercase',
1923
+ },
1924
+ ]}
1925
+ />
1926
+ </CardHeader>
1927
+ </Card>
1928
+
1929
+ <Card className="border-border/60 shadow-sm">
1930
+ <CardContent className="p-3.5">
1931
+ <Tabs defaultValue="basic-info" className="space-y-3">
1932
+ <TabsList className="grid w-full grid-cols-2">
1933
+ <TabsTrigger value="basic-info">{t('tabBasicInfo')}</TabsTrigger>
1934
+ <TabsTrigger value="roles">{t('tabRoles')}</TabsTrigger>
1935
+ </TabsList>
1936
+
1937
+ <TabsContent value="basic-info" className="space-y-3">
1938
+ <div>
1939
+ <h3 className="text-sm font-semibold">
1940
+ {t('selectedMenuTitle')}
1941
+ </h3>
1942
+ <p className="mt-1 text-xs text-muted-foreground">
1943
+ {t('selectedMenuDescription')}
1944
+ </p>
1945
+ </div>
1946
+
1947
+ <Form {...editForm}>
1948
+ <form
1949
+ onSubmit={editForm.handleSubmit(onEditSubmit)}
1950
+ className="space-y-3"
1951
+ >
1952
+ <FormField
1953
+ control={editForm.control}
1954
+ name="name"
1955
+ render={({ field }) => (
1956
+ <FormItem>
1957
+ <FormLabel>{t('editNameLabel')}</FormLabel>
1958
+ <FormControl>
1959
+ <Input
1960
+ placeholder={t('editNamePlaceholder')}
1961
+ {...field}
1962
+ />
1963
+ </FormControl>
1964
+ <FormMessage />
1965
+ </FormItem>
1966
+ )}
1967
+ />
1968
+
1969
+ <div className="grid gap-4 md:grid-cols-2">
1970
+ <FormField
1971
+ control={editForm.control}
1972
+ name="slug"
1973
+ render={({ field }) => (
1974
+ <FormItem>
1975
+ <FormLabel>{t('editSlugLabel')}</FormLabel>
1976
+ <FormControl>
1977
+ <Input {...field} />
1978
+ </FormControl>
1979
+ <FormMessage />
1980
+ </FormItem>
1981
+ )}
1982
+ />
1983
+
1984
+ <FormField
1985
+ control={editForm.control}
1986
+ name="url"
1987
+ render={({ field }) => (
1988
+ <FormItem>
1989
+ <FormLabel>{t('editUrlLabel')}</FormLabel>
1990
+ <FormControl>
1991
+ <Input {...field} />
1992
+ </FormControl>
1993
+ <FormMessage />
1994
+ </FormItem>
1995
+ )}
1996
+ />
1997
+ </div>
1998
+
1999
+ <div className="grid gap-4 md:grid-cols-2">
2000
+ <FormField
2001
+ control={editForm.control}
2002
+ name="icon"
2003
+ render={({ field }) => (
2004
+ <FormItem>
2005
+ <FormLabel>{t('editIconLabel')}</FormLabel>
2006
+ <FormControl>
2007
+ <Input {...field} value={field.value ?? ''} />
2008
+ </FormControl>
2009
+ <FormMessage />
2010
+ </FormItem>
2011
+ )}
2012
+ />
2013
+
2014
+ <FormField
2015
+ control={editForm.control}
2016
+ name="order"
2017
+ render={({ field }) => (
2018
+ <FormItem>
2019
+ <FormLabel>{t('editOrderLabel')}</FormLabel>
2020
+ <FormControl>
2021
+ <Input
2022
+ type="number"
2023
+ min={1}
2024
+ {...field}
2025
+ value={field.value ?? ''}
2026
+ />
2027
+ </FormControl>
2028
+ <FormMessage />
2029
+ </FormItem>
2030
+ )}
2031
+ />
2032
+ </div>
2033
+
2034
+ <FormField
2035
+ control={editForm.control}
2036
+ name="menu_id"
2037
+ render={({ field }) => (
2038
+ <FormItem>
2039
+ <FormLabel>{t('editParentMenuLabel')}</FormLabel>
2040
+ <Select
2041
+ value={field.value ?? NO_PARENT}
2042
+ onValueChange={field.onChange}
2043
+ >
2044
+ <FormControl>
2045
+ <SelectTrigger className="w-full">
2046
+ <SelectValue
2047
+ placeholder={t('editParentMenuPlaceholder')}
2048
+ />
2049
+ </SelectTrigger>
2050
+ </FormControl>
2051
+ <SelectContent className="w-full">
2052
+ <SelectItem value={NO_PARENT}>
2053
+ {t('formParentMenuNone')}
2054
+ </SelectItem>
2055
+ {parentMenuOptions.map((menu) => (
2056
+ <SelectItem key={menu.id} value={String(menu.id)}>
2057
+ {getMenuDisplayName(menu)}
2058
+ </SelectItem>
2059
+ ))}
2060
+ </SelectContent>
2061
+ </Select>
2062
+ <FormMessage />
2063
+ </FormItem>
2064
+ )}
2065
+ />
2066
+
2067
+ {editFormError ? (
2068
+ <Alert
2069
+ variant="destructive"
2070
+ className="rounded-md border-red-300 bg-red-50 p-4"
2071
+ >
2072
+ <AlertTitle className="text-sm">
2073
+ {t('verifyYourInput')}
2074
+ </AlertTitle>
2075
+ <AlertDescription className="text-sm">
2076
+ {editFormError}
2077
+ </AlertDescription>
2078
+ </Alert>
2079
+ ) : null}
2080
+
2081
+ <div className="flex flex-col gap-2 sm:flex-row">
2082
+ <Button
2083
+ type="submit"
2084
+ className="cursor-pointer sm:min-w-36"
2085
+ >
2086
+ {t('saveChanges')}
2087
+ </Button>
2088
+ <Button
2089
+ type="button"
2090
+ variant="outline"
2091
+ className="cursor-pointer"
2092
+ onClick={() => void handleMoveToRoot(editingMenu.id)}
2093
+ >
2094
+ <House className="mr-1 h-4 w-4" />
2095
+ {t('moveToRoot')}
2096
+ </Button>
2097
+ </div>
2098
+ </form>
2099
+ </Form>
2100
+ </TabsContent>
2101
+
2102
+ <TabsContent value="roles">
2103
+ <MenuRolesSection menuId={editingMenu.id} />
2104
+ </TabsContent>
2105
+ </Tabs>
2106
+ </CardContent>
2107
+ </Card>
2108
+ </div>
2109
+ ) : (
2110
+ <EmptyState
2111
+ className="h-full min-h-105"
2112
+ icon={<GitBranch className="h-12 w-12" />}
2113
+ title={t('noMenuSelectedTitle')}
2114
+ description={t('noMenuSelectedDescription')}
2115
+ actionLabel={t('buttonAddMenu')}
2116
+ onAction={() => openCreateMenu()}
2117
+ />
897
2118
  );
898
2119
 
899
2120
  return (
@@ -901,50 +2122,152 @@ export default function MenuPage() {
901
2122
  <PageHeader
902
2123
  breadcrumbs={[{ label: 'Home', href: '/' }, { label: t('menus') }]}
903
2124
  actions={[
904
- {
905
- label: t('buttonViewTree'),
906
- onClick: () => setIsTreeOpen(true),
907
- variant: 'outline',
908
- icon: <GitBranch className="h-4 w-4" />,
909
- },
910
2125
  {
911
2126
  label: t('buttonAddMenu'),
912
- onClick: () => setIsDialogOpen(true),
2127
+ onClick: () => openCreateMenu(),
913
2128
  variant: 'default',
2129
+ icon: <Plus className="h-4 w-4" />,
914
2130
  },
915
2131
  ]}
916
2132
  title={t('title')}
917
2133
  description={t('description')}
918
2134
  />
919
2135
 
920
- <StatsCards
921
- className="sm:grid-cols-1 md:grid-cols-1 lg:grid-cols-1"
922
- stats={[
2136
+ <KpiCardsGrid
2137
+ columns={4}
2138
+ className="mt-3 gap-3"
2139
+ cardClassName="shadow-sm"
2140
+ items={[
923
2141
  {
2142
+ key: 'total-menus',
924
2143
  title: t('totalMenus'),
925
2144
  value: String(menuStats?.total ?? 0),
926
- icon: <Menu className="h-5 w-5" />,
927
- iconBgColor: 'bg-blue-50',
928
- iconColor: 'text-blue-600',
2145
+ icon: Menu,
2146
+ layout: 'compact',
2147
+ accentClassName: 'from-blue-500/20 via-sky-500/10 to-transparent',
2148
+ iconContainerClassName: 'bg-blue-50 text-blue-600',
2149
+ },
2150
+ {
2151
+ key: 'root-menus',
2152
+ title: t('rootMenuLabel'),
2153
+ value: String(rootMenusCount),
2154
+ icon: House,
2155
+ layout: 'compact',
2156
+ accentClassName:
2157
+ 'from-emerald-500/20 via-green-500/10 to-transparent',
2158
+ iconContainerClassName: 'bg-emerald-50 text-emerald-600',
2159
+ },
2160
+ {
2161
+ key: 'submenus',
2162
+ title: t('totalSubmenus'),
2163
+ value: String(subMenuCount),
2164
+ icon: GitBranch,
2165
+ layout: 'compact',
2166
+ accentClassName:
2167
+ 'from-violet-500/20 via-fuchsia-500/10 to-transparent',
2168
+ iconContainerClassName: 'bg-violet-50 text-violet-600',
2169
+ },
2170
+ {
2171
+ key: 'search-or-children',
2172
+ title: highlightKpiTitle,
2173
+ value: highlightKpiValue,
2174
+ icon: PanelLeftOpen,
2175
+ layout: 'compact',
2176
+ accentClassName:
2177
+ 'from-amber-500/20 via-yellow-500/10 to-transparent',
2178
+ iconContainerClassName: 'bg-amber-50 text-amber-700',
929
2179
  },
930
2180
  ]}
931
2181
  />
932
2182
 
933
- <SearchBar
934
- searchQuery={searchQuery}
935
- onSearchChange={setSearchQuery}
936
- onSearch={() => refetch()}
937
- placeholder={t('searchPlaceholder')}
938
- className="mt-4"
939
- />
2183
+ <div className="mt-3 flex flex-col gap-2.5 xl:flex-row xl:items-center xl:justify-between">
2184
+ <SearchBar
2185
+ searchQuery={searchQuery}
2186
+ onSearchChange={setSearchQuery}
2187
+ onSearch={() => {
2188
+ void Promise.all([refetch(), refetchAllMenus()]);
2189
+ }}
2190
+ placeholder={t('searchPlaceholder')}
2191
+ className="mt-0 flex-1"
2192
+ />
2193
+
2194
+ <div className="flex flex-wrap items-center gap-2 xl:justify-end">
2195
+ <Button
2196
+ variant="outline"
2197
+ className="cursor-pointer lg:hidden"
2198
+ onClick={() => setIsTreeOpen(true)}
2199
+ >
2200
+ <PanelLeftOpen className="mr-1 h-4 w-4" />
2201
+ {t('openTreeOnMobile')}
2202
+ </Button>
2203
+
2204
+ <Button
2205
+ variant="outline"
2206
+ className="cursor-pointer"
2207
+ onClick={() => {
2208
+ void refreshWorkspace(editingMenu?.id ?? null);
2209
+ }}
2210
+ >
2211
+ <RefreshCcw className="mr-1 h-4 w-4" />
2212
+ {t('refresh')}
2213
+ </Button>
2214
+ </div>
2215
+ </div>
2216
+
2217
+ <div className="mt-3 hidden lg:block">
2218
+ <ResizablePanelGroup
2219
+ direction="horizontal"
2220
+ className="min-h-145 overflow-hidden rounded-xl border border-border/60 bg-card shadow-sm"
2221
+ >
2222
+ <ResizablePanel defaultSize={38} minSize={28} className="p-3">
2223
+ <MenuTreeWorkspace
2224
+ menus={(allMenus ?? []) as TreeNode[]}
2225
+ searchQuery={searchQuery}
2226
+ selectedId={editingMenu?.id ?? null}
2227
+ onSelect={async (menuId) => {
2228
+ await handleEdit({ id: menuId });
2229
+ }}
2230
+ onSaved={async () => {
2231
+ await refreshWorkspace(editingMenu?.id ?? null);
2232
+ }}
2233
+ onAddSubmenu={(menuId) => openCreateMenu(menuId)}
2234
+ onDuplicate={handleDuplicateMenu}
2235
+ onDelete={requestDeleteMenu}
2236
+ onMoveToRoot={handleMoveToRoot}
2237
+ />
2238
+ </ResizablePanel>
2239
+
2240
+ <ResizableHandle withHandle />
2241
+
2242
+ <ResizablePanel
2243
+ defaultSize={62}
2244
+ minSize={36}
2245
+ className="bg-muted/10 p-3"
2246
+ >
2247
+ {detailPanel}
2248
+ </ResizablePanel>
2249
+ </ResizablePanelGroup>
2250
+ </div>
2251
+
2252
+ <div className="mt-3 space-y-3 lg:hidden">{detailPanel}</div>
940
2253
 
941
- <div className="flex-1 pt-4">
942
- {isLoading && (
943
- <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
944
- {Array.from({ length: 3 }).map((_, i) => (
2254
+ <div className="pt-4 lg:hidden">
2255
+ <div className="mb-3 flex items-center justify-between gap-3">
2256
+ <div>
2257
+ <h3 className="text-sm font-semibold">{t('menus')}</h3>
2258
+ <p className="text-xs text-muted-foreground">{t('description')}</p>
2259
+ </div>
2260
+ <Badge variant="outline" className="rounded-full px-2.5 py-1">
2261
+ {menusResponse?.total ?? 0}
2262
+ </Badge>
2263
+ </div>
2264
+
2265
+ {isLoading ? (
2266
+ <div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
2267
+ {Array.from({ length: 3 }).map((_, index) => (
945
2268
  <Card
946
- key={`skeleton-${i}`}
947
- className="flex flex-col justify-between gap-2 rounded-2xl border border-border/60 bg-card p-4 shadow-sm animate-pulse"
2269
+ key={`skeleton-${index}`}
2270
+ className="animate-pulse rounded-2xl border border-border/60 bg-card p-4 shadow-sm"
948
2271
  >
949
2272
  <CardHeader className="p-0">
950
2273
  <div className="space-y-2">
@@ -955,113 +2278,209 @@ export default function MenuPage() {
955
2278
  </Card>
956
2279
  ))}
957
2280
  </div>
958
- )}
959
-
960
- {!isLoading &&
961
- (!menusResponse?.data || menusResponse.data.length === 0) ? (
2281
+ ) : !menusResponse?.data || menusResponse.data.length === 0 ? (
962
2282
  <EmptyState
963
2283
  icon={<Menu className="h-12 w-12" />}
964
2284
  title={t('noMenusFound')}
965
2285
  description={t('description')}
966
2286
  actionLabel={t('buttonAddMenu')}
967
- onAction={() => setIsDialogOpen(true)}
2287
+ onAction={() => openCreateMenu()}
968
2288
  />
969
2289
  ) : (
970
- <div className="grid gap-4 grid-cols-1 lg:grid-cols-2 xl:grid-cols-3">
971
- {menusResponse?.data?.map((menu: MenuItem) => (
972
- <Card
973
- key={String(menu.id)}
974
- onDoubleClick={() => handleEdit(menu)}
975
- className="cursor-pointer rounded-md flex flex-col justify-between gap-2 border border-border/60 bg-card p-4 shadow-sm transition hover:border-primary"
976
- >
977
- <CardHeader className="flex items-start justify-between gap-4 p-0">
978
- <div className="flex items-center gap-3 flex-1">
979
- <div className="h-12 w-12 shrink-0 rounded-full bg-primary/10 flex items-center justify-center">
980
- <Menu className="h-6 w-6 text-primary" />
981
- </div>
982
- <div className="flex-1 min-w-0">
983
- <CardTitle className="text-sm font-semibold truncate">
984
- {getMenuDisplayName(menu)}
985
- </CardTitle>
986
- <CardDescription className="text-xs text-muted-foreground flex items-center gap-1 mt-0.5">
987
- <Link className="h-3 w-3" />
988
- <span className="truncate">{menu.url}</span>
989
- </CardDescription>
990
- <div className="flex gap-1 mt-1 flex-wrap">
991
- <Badge
992
- variant="secondary"
993
- className="text-xs px-1.5 py-0"
994
- >
995
- {menu.slug}
996
- </Badge>
997
- {menu.order != null && (
998
- <Badge
999
- variant="outline"
1000
- className="text-xs px-1.5 py-0"
1001
- >
1002
- #{menu.order}
1003
- </Badge>
2290
+ <div className="grid grid-cols-1 gap-4 xl:grid-cols-2">
2291
+ {menusResponse.data.map((menu: MenuItem) => {
2292
+ const visual = getMenuVisual(menu);
2293
+ const CardIcon = visual.Icon;
2294
+ const roleCount = getMenuRoleCount(menu);
2295
+
2296
+ return (
2297
+ <Card
2298
+ key={menu.id}
2299
+ onClick={() => {
2300
+ void handleEdit(menu);
2301
+ }}
2302
+ className={cn(
2303
+ 'cursor-pointer rounded-2xl border border-border/60 bg-card p-4 shadow-sm transition hover:border-primary hover:shadow-md active:scale-[0.99]',
2304
+ editingMenu?.id === menu.id
2305
+ ? 'border-primary bg-linear-to-r from-primary/5 to-background shadow-md'
2306
+ : ''
2307
+ )}
2308
+ >
2309
+ <CardHeader className="flex items-start justify-between gap-4 p-0">
2310
+ <div className="flex min-w-0 flex-1 items-center gap-3">
2311
+ <div
2312
+ className={cn(
2313
+ 'flex h-12 w-12 shrink-0 items-center justify-center rounded-full',
2314
+ visual.iconWrapperClassName
1004
2315
  )}
1005
- {menu.menu_id != null && (
1006
- <Badge
2316
+ >
2317
+ <CardIcon
2318
+ className={cn('h-6 w-6', visual.iconClassName)}
2319
+ />
2320
+ </div>
2321
+
2322
+ <div className="min-w-0 flex-1">
2323
+ <CardTitle className="truncate text-sm font-semibold">
2324
+ {getMenuDisplayName(menu)}
2325
+ </CardTitle>
2326
+ <CardDescription className="mt-0.5 flex items-center gap-1 text-xs text-muted-foreground">
2327
+ <Link className="h-3 w-3" />
2328
+ <span className="truncate">{menu.url || '/'}</span>
2329
+ </CardDescription>
2330
+ <div className="mt-2 flex flex-wrap gap-1">
2331
+ <TooltipBadge
2332
+ tooltip={menu.slug}
2333
+ variant="secondary"
2334
+ className="px-1.5 py-0 text-xs"
2335
+ >
2336
+ {menu.slug}
2337
+ </TooltipBadge>
2338
+ {menu.order != null ? (
2339
+ <TooltipBadge
2340
+ tooltip={`${t('editOrderLabel')}: #${menu.order}`}
2341
+ variant="outline"
2342
+ className="px-1.5 py-0 text-xs"
2343
+ >
2344
+ #{menu.order}
2345
+ </TooltipBadge>
2346
+ ) : null}
2347
+ {menu.menu_id != null ? (
2348
+ <TooltipBadge
2349
+ tooltip={t('subMenu')}
2350
+ variant="outline"
2351
+ className="px-1.5 py-0 text-xs"
2352
+ >
2353
+ {t('subMenu')}
2354
+ </TooltipBadge>
2355
+ ) : (
2356
+ <TooltipBadge
2357
+ tooltip={t('rootMenuLabel')}
2358
+ variant="outline"
2359
+ className="px-1.5 py-0 text-xs"
2360
+ >
2361
+ {t('rootMenuLabel')}
2362
+ </TooltipBadge>
2363
+ )}
2364
+ <TooltipBadge
2365
+ tooltip={`${t('tabRoles')}: ${roleCount}`}
1007
2366
  variant="outline"
1008
- className="text-xs px-1.5 py-0"
2367
+ className={cn(
2368
+ 'px-1.5 py-0 text-xs',
2369
+ roleCount > 0
2370
+ ? 'border-primary/30 bg-primary/5 text-primary'
2371
+ : 'text-muted-foreground'
2372
+ )}
1009
2373
  >
1010
- {t('subMenu')}
1011
- </Badge>
1012
- )}
2374
+ <ShieldCheck className="mr-1 h-3 w-3" />
2375
+ {roleCount}
2376
+ </TooltipBadge>
2377
+ </div>
1013
2378
  </div>
1014
2379
  </div>
1015
- </div>
1016
- <Button
1017
- variant="outline"
1018
- size="sm"
1019
- onClick={() => handleEdit(menu)}
1020
- >
1021
- {t('buttonEditMenu')}
1022
- </Button>
1023
- </CardHeader>
1024
- </Card>
1025
- ))}
2380
+
2381
+ <Button
2382
+ variant="outline"
2383
+ size="sm"
2384
+ className="cursor-pointer"
2385
+ onClick={(event) => {
2386
+ event.stopPropagation();
2387
+ void handleEdit(menu);
2388
+ }}
2389
+ >
2390
+ {t('buttonEditMenu')}
2391
+ </Button>
2392
+ </CardHeader>
2393
+ </Card>
2394
+ );
2395
+ })}
1026
2396
  </div>
1027
2397
  )}
2398
+ </div>
1028
2399
 
1029
- <div className="w-full border-t pt-2 mt-4">
1030
- <PaginationFooter
1031
- currentPage={page}
1032
- pageSize={pageSize}
1033
- totalItems={menusResponse?.total || 0}
1034
- onPageChange={setPage}
1035
- onPageSizeChange={(size) => {
1036
- setPageSize(size);
1037
- setPage(1);
1038
- }}
1039
- pageSizeOptions={[6, 12, 24, 48]}
1040
- />
1041
- </div>
2400
+ <div className="mt-4 w-full border-t pt-2 lg:hidden">
2401
+ <PaginationFooter
2402
+ currentPage={page}
2403
+ pageSize={pageSize}
2404
+ totalItems={menusResponse?.total || 0}
2405
+ onPageChange={setPage}
2406
+ onPageSizeChange={(size) => {
2407
+ setPageSize(size);
2408
+ setPage(1);
2409
+ }}
2410
+ pageSizeOptions={[6, 12, 24, 48]}
2411
+ />
2412
+ </div>
1042
2413
 
1043
- <Sheet open={isDialogOpen} onOpenChange={setIsDialogOpen}>
1044
- <SheetContent className="w-full sm:max-w-lg overflow-y-auto gap-0">
1045
- <SheetHeader>
1046
- <SheetTitle>{t('dialogAddMenuTitle')}</SheetTitle>
1047
- <SheetDescription>
1048
- {t('dialogAddMenuDescription')}
1049
- </SheetDescription>
1050
- </SheetHeader>
1051
- <Form {...form}>
1052
- <form
1053
- onSubmit={form.handleSubmit(onSubmit)}
1054
- className="space-y-4 px-4 pt-2"
1055
- >
2414
+ <Sheet open={isDialogOpen} onOpenChange={setIsDialogOpen}>
2415
+ <SheetContent className="w-full gap-0 overflow-y-auto sm:max-w-lg">
2416
+ <SheetHeader>
2417
+ <SheetTitle>{t('dialogAddMenuTitle')}</SheetTitle>
2418
+ <SheetDescription>{t('dialogAddMenuDescription')}</SheetDescription>
2419
+ </SheetHeader>
2420
+
2421
+ <Form {...form}>
2422
+ <form
2423
+ onSubmit={form.handleSubmit(onSubmit)}
2424
+ className="space-y-4 px-4 pt-2"
2425
+ >
2426
+ <FormField
2427
+ control={form.control}
2428
+ name="slug"
2429
+ render={({ field }) => (
2430
+ <FormItem>
2431
+ <FormLabel>{t('formSlugLabel')}</FormLabel>
2432
+ <FormControl>
2433
+ <Input
2434
+ placeholder={t('formSlugPlaceholder')}
2435
+ {...field}
2436
+ />
2437
+ </FormControl>
2438
+ <FormMessage />
2439
+ </FormItem>
2440
+ )}
2441
+ />
2442
+
2443
+ <FormField
2444
+ control={form.control}
2445
+ name="url"
2446
+ render={({ field }) => (
2447
+ <FormItem>
2448
+ <FormLabel>{t('formUrlLabel')}</FormLabel>
2449
+ <FormControl>
2450
+ <Input placeholder={t('formUrlPlaceholder')} {...field} />
2451
+ </FormControl>
2452
+ <FormMessage />
2453
+ </FormItem>
2454
+ )}
2455
+ />
2456
+
2457
+ <FormField
2458
+ control={form.control}
2459
+ name="name"
2460
+ render={({ field }) => (
2461
+ <FormItem>
2462
+ <FormLabel>{t('formNameLabel')}</FormLabel>
2463
+ <FormControl>
2464
+ <Input
2465
+ placeholder={t('formNamePlaceholder')}
2466
+ {...field}
2467
+ />
2468
+ </FormControl>
2469
+ <FormMessage />
2470
+ </FormItem>
2471
+ )}
2472
+ />
2473
+
2474
+ <div className="grid grid-cols-2 gap-4">
1056
2475
  <FormField
1057
2476
  control={form.control}
1058
- name="slug"
2477
+ name="icon"
1059
2478
  render={({ field }) => (
1060
2479
  <FormItem>
1061
- <FormLabel>{t('formSlugLabel')}</FormLabel>
2480
+ <FormLabel>{t('formIconLabel')}</FormLabel>
1062
2481
  <FormControl>
1063
2482
  <Input
1064
- placeholder={t('formSlugPlaceholder')}
2483
+ placeholder={t('formIconPlaceholder')}
1065
2484
  {...field}
1066
2485
  />
1067
2486
  </FormControl>
@@ -1069,379 +2488,133 @@ export default function MenuPage() {
1069
2488
  </FormItem>
1070
2489
  )}
1071
2490
  />
2491
+
1072
2492
  <FormField
1073
2493
  control={form.control}
1074
- name="url"
2494
+ name="order"
1075
2495
  render={({ field }) => (
1076
2496
  <FormItem>
1077
- <FormLabel>{t('formUrlLabel')}</FormLabel>
2497
+ <FormLabel>{t('formOrderLabel')}</FormLabel>
1078
2498
  <FormControl>
1079
2499
  <Input
1080
- placeholder={t('formUrlPlaceholder')}
2500
+ type="number"
2501
+ min={1}
2502
+ placeholder={t('formOrderPlaceholder')}
1081
2503
  {...field}
2504
+ value={field.value ?? ''}
1082
2505
  />
1083
2506
  </FormControl>
1084
2507
  <FormMessage />
1085
2508
  </FormItem>
1086
2509
  )}
1087
2510
  />
1088
- <FormField
1089
- control={form.control}
1090
- name="name"
1091
- render={({ field }) => (
1092
- <FormItem>
1093
- <FormLabel>{t('formNameLabel')}</FormLabel>
2511
+ </div>
2512
+
2513
+ <FormField
2514
+ control={form.control}
2515
+ name="menu_id"
2516
+ render={({ field }) => (
2517
+ <FormItem>
2518
+ <FormLabel>{t('formParentMenuLabel')}</FormLabel>
2519
+ <Select
2520
+ value={field.value ?? NO_PARENT}
2521
+ onValueChange={field.onChange}
2522
+ >
1094
2523
  <FormControl>
1095
- <Input
1096
- placeholder={t('formNamePlaceholder')}
1097
- {...field}
1098
- />
1099
- </FormControl>
1100
- <FormMessage />
1101
- </FormItem>
1102
- )}
1103
- />
1104
- <div className="grid grid-cols-2 gap-4">
1105
- <FormField
1106
- control={form.control}
1107
- name="icon"
1108
- render={({ field }) => (
1109
- <FormItem>
1110
- <FormLabel>{t('formIconLabel')}</FormLabel>
1111
- <FormControl>
1112
- <Input
1113
- placeholder={t('formIconPlaceholder')}
1114
- {...field}
2524
+ <SelectTrigger className="w-full">
2525
+ <SelectValue
2526
+ placeholder={t('formParentMenuPlaceholder')}
1115
2527
  />
1116
- </FormControl>
1117
- <FormMessage />
1118
- </FormItem>
1119
- )}
1120
- />
1121
- <FormField
1122
- control={form.control}
1123
- name="order"
1124
- render={({ field }) => (
1125
- <FormItem>
1126
- <FormLabel>{t('formOrderLabel')}</FormLabel>
1127
- <FormControl>
1128
- <Input
1129
- type="number"
1130
- min={1}
1131
- placeholder={t('formOrderPlaceholder')}
1132
- {...field}
1133
- value={field.value ?? ''}
1134
- />
1135
- </FormControl>
1136
- <FormMessage />
1137
- </FormItem>
1138
- )}
1139
- />
1140
- </div>
1141
-
1142
- <FormField
1143
- control={form.control}
1144
- name="menu_id"
1145
- render={({ field }) => (
1146
- <FormItem>
1147
- <FormLabel>{t('formParentMenuLabel')}</FormLabel>
1148
- <Select
1149
- value={field.value ?? NO_PARENT}
1150
- onValueChange={field.onChange}
1151
- >
1152
- <FormControl>
1153
- <SelectTrigger className="w-full">
1154
- <SelectValue
1155
- placeholder={t('formParentMenuPlaceholder')}
1156
- />
1157
- </SelectTrigger>
1158
- </FormControl>
1159
- <SelectContent className="w-full">
1160
- <SelectItem value={NO_PARENT}>
1161
- {t('formParentMenuNone')}
2528
+ </SelectTrigger>
2529
+ </FormControl>
2530
+ <SelectContent className="w-full">
2531
+ <SelectItem value={NO_PARENT}>
2532
+ {t('formParentMenuNone')}
2533
+ </SelectItem>
2534
+ {(allMenus ?? []).map((menu) => (
2535
+ <SelectItem key={menu.id} value={String(menu.id)}>
2536
+ {getMenuDisplayName(menu)}
1162
2537
  </SelectItem>
1163
- {(allMenus ?? []).map((m) => (
1164
- <SelectItem key={m.id} value={String(m.id)}>
1165
- {getMenuDisplayName(m)}
1166
- </SelectItem>
1167
- ))}
1168
- </SelectContent>
1169
- </Select>
1170
- <FormMessage />
1171
- </FormItem>
1172
- )}
1173
- />
1174
-
1175
- {formError && (
1176
- <Alert
1177
- variant="destructive"
1178
- className="border-red-300 bg-red-50 rounded-md p-4"
1179
- >
1180
- <AlertTitle className="text-sm">
1181
- {t('verifyYourInput')}
1182
- </AlertTitle>
1183
- <AlertDescription className="text-sm">
1184
- {formError}
1185
- </AlertDescription>
1186
- </Alert>
2538
+ ))}
2539
+ </SelectContent>
2540
+ </Select>
2541
+ <FormMessage />
2542
+ </FormItem>
1187
2543
  )}
2544
+ />
1188
2545
 
1189
- <Button type="submit" className="w-full">
1190
- <Plus className="h-4 w-4 mr-1" />
1191
- {t('buttonAddMenu')}
1192
- </Button>
1193
- </form>
1194
- </Form>
1195
- </SheetContent>
1196
- </Sheet>
1197
-
1198
- {editingMenu && (
1199
- <Sheet open={!!editingMenu} onOpenChange={() => setEditingMenu(null)}>
1200
- <SheetContent className="w-full sm:max-w-lg overflow-y-auto p-0">
1201
- <SheetHeader className="px-6 pt-6 pb-4 border-b">
1202
- <div className="flex items-start justify-between gap-3">
1203
- <div className="min-w-0">
1204
- <SheetTitle>{t('titleEditMenu')}</SheetTitle>
1205
- <SheetDescription className="mt-0.5 truncate">
1206
- {getMenuDisplayName(editingMenu)}
1207
- </SheetDescription>
1208
- </div>
1209
- <Select
1210
- value={selectedLocale}
1211
- onValueChange={(value) => {
1212
- const currentName = editForm.getValues('name');
1213
- setEditingMenu((prev) => {
1214
- if (!prev) return prev;
1215
- return {
1216
- ...prev,
1217
- locale: {
1218
- ...prev.locale,
1219
- [selectedLocale]: { name: currentName ?? '' },
1220
- },
1221
- };
1222
- });
1223
- setSelectedLocale(value);
1224
- }}
1225
- >
1226
- <SelectTrigger className="w-[180px] h-8 shrink-0">
1227
- <SelectValue />
1228
- </SelectTrigger>
1229
- <SelectContent>
1230
- {(locales ?? []).map((locale: any) => (
1231
- <SelectItem key={locale.code} value={locale.code}>
1232
- {locale.name}
1233
- </SelectItem>
1234
- ))}
1235
- </SelectContent>
1236
- </Select>
1237
- </div>
1238
- </SheetHeader>
1239
-
1240
- <div className="px-6 py-4">
1241
- <Tabs defaultValue="basic-info">
1242
- <TabsList className="w-full">
1243
- <TabsTrigger value="basic-info" className="flex-1">
1244
- {t('tabBasicInfo')}
1245
- </TabsTrigger>
1246
- <TabsTrigger value="roles" className="flex-1">
1247
- {t('tabRoles')}
1248
- </TabsTrigger>
1249
- </TabsList>
1250
-
1251
- <TabsContent value="basic-info" className="mt-4 space-y-6">
1252
- <Form {...editForm}>
1253
- <form
1254
- onSubmit={editForm.handleSubmit(onEditSubmit)}
1255
- className="space-y-4"
1256
- >
1257
- <FormField
1258
- control={editForm.control}
1259
- name="name"
1260
- render={({ field }) => (
1261
- <FormItem>
1262
- <FormLabel>{t('editNameLabel')}</FormLabel>
1263
- <FormControl>
1264
- <Input
1265
- placeholder={t('editNamePlaceholder')}
1266
- {...field}
1267
- />
1268
- </FormControl>
1269
- <FormMessage />
1270
- </FormItem>
1271
- )}
1272
- />
1273
- <FormField
1274
- control={editForm.control}
1275
- name="slug"
1276
- render={({ field }) => (
1277
- <FormItem>
1278
- <FormLabel>{t('editSlugLabel')}</FormLabel>
1279
- <FormControl>
1280
- <Input {...field} />
1281
- </FormControl>
1282
- <FormMessage />
1283
- </FormItem>
1284
- )}
1285
- />
1286
- <FormField
1287
- control={editForm.control}
1288
- name="url"
1289
- render={({ field }) => (
1290
- <FormItem>
1291
- <FormLabel>{t('editUrlLabel')}</FormLabel>
1292
- <FormControl>
1293
- <Input {...field} />
1294
- </FormControl>
1295
- <FormMessage />
1296
- </FormItem>
1297
- )}
1298
- />
1299
- <div className="grid grid-cols-2 gap-4">
1300
- <FormField
1301
- control={editForm.control}
1302
- name="icon"
1303
- render={({ field }) => (
1304
- <FormItem>
1305
- <FormLabel>{t('editIconLabel')}</FormLabel>
1306
- <FormControl>
1307
- <Input {...field} value={field.value ?? ''} />
1308
- </FormControl>
1309
- <FormMessage />
1310
- </FormItem>
1311
- )}
1312
- />
1313
- <FormField
1314
- control={editForm.control}
1315
- name="order"
1316
- render={({ field }) => (
1317
- <FormItem>
1318
- <FormLabel>{t('editOrderLabel')}</FormLabel>
1319
- <FormControl>
1320
- <Input
1321
- type="number"
1322
- min={1}
1323
- {...field}
1324
- value={field.value ?? ''}
1325
- />
1326
- </FormControl>
1327
- <FormMessage />
1328
- </FormItem>
1329
- )}
1330
- />
1331
- </div>
1332
-
1333
- <FormField
1334
- control={editForm.control}
1335
- name="menu_id"
1336
- render={({ field }) => (
1337
- <FormItem>
1338
- <FormLabel>{t('editParentMenuLabel')}</FormLabel>
1339
- <Select
1340
- value={field.value ?? NO_PARENT}
1341
- onValueChange={field.onChange}
1342
- >
1343
- <FormControl>
1344
- <SelectTrigger className="w-full">
1345
- <SelectValue
1346
- placeholder={t(
1347
- 'editParentMenuPlaceholder'
1348
- )}
1349
- />
1350
- </SelectTrigger>
1351
- </FormControl>
1352
- <SelectContent className="w-full">
1353
- <SelectItem value={NO_PARENT}>
1354
- {t('formParentMenuNone')}
1355
- </SelectItem>
1356
- {parentMenuOptions.map((m) => (
1357
- <SelectItem key={m.id} value={String(m.id)}>
1358
- {getMenuDisplayName(m)}
1359
- </SelectItem>
1360
- ))}
1361
- </SelectContent>
1362
- </Select>
1363
- <FormMessage />
1364
- </FormItem>
1365
- )}
1366
- />
1367
-
1368
- {editFormError && (
1369
- <Alert
1370
- variant="destructive"
1371
- className="border-red-300 bg-red-50 rounded-md p-4"
1372
- >
1373
- <AlertTitle className="text-sm">
1374
- {t('verifyYourInput')}
1375
- </AlertTitle>
1376
- <AlertDescription className="text-sm">
1377
- {editFormError}
1378
- </AlertDescription>
1379
- </Alert>
1380
- )}
1381
-
1382
- <div className="flex flex-col w-full gap-2 pt-2">
1383
- <Button type="submit" className="w-full">
1384
- {t('saveChanges')}
1385
- </Button>
1386
- <Button
1387
- className="w-full"
1388
- type="button"
1389
- variant="outline"
1390
- onClick={() => setEditingMenu(null)}
1391
- >
1392
- {t('cancel')}
1393
- </Button>
1394
- </div>
1395
- </form>
1396
- </Form>
1397
-
1398
- <div className="border-t pt-4">
1399
- <Button
1400
- className="w-full cursor-pointer"
1401
- variant="destructive"
1402
- onClick={() => setOpenDeleteModal(true)}
1403
- >
1404
- <Trash2 className="w-4 h-4" />
1405
- <span>{t('buttonDeleteMenu')}</span>
1406
- </Button>
1407
- </div>
1408
- </TabsContent>
1409
-
1410
- <TabsContent value="roles" className="mt-4">
1411
- <MenuRolesSection menuId={editingMenu.id} />
1412
- </TabsContent>
1413
- </Tabs>
1414
- </div>
1415
- </SheetContent>
1416
- </Sheet>
1417
- )}
1418
-
1419
- <AlertDialog open={openDeleteModal} onOpenChange={setOpenDeleteModal}>
1420
- <AlertDialogContent>
1421
- <AlertDialogHeader>
1422
- <AlertDialogTitle>{t('dialogDeleteMenuTitle')}</AlertDialogTitle>
1423
- <AlertDialogDescription>
1424
- {t('dialogDeleteMenuDescription')}
1425
- </AlertDialogDescription>
1426
- </AlertDialogHeader>
1427
- <AlertDialogFooter>
1428
- <AlertDialogCancel>{t('deleteMenuCancel')}</AlertDialogCancel>
1429
- <AlertDialogAction onClick={onDelete}>
1430
- {t('deleteMenuConfirm')}
1431
- </AlertDialogAction>
1432
- </AlertDialogFooter>
1433
- </AlertDialogContent>
1434
- </AlertDialog>
1435
-
1436
- <MenuTreeDialog
1437
- open={isTreeOpen}
1438
- onClose={() => setIsTreeOpen(false)}
1439
- menus={(allMenus ?? []) as TreeNode[]}
1440
- onSaved={() => {
1441
- refetch();
1442
- }}
1443
- />
1444
- </div>
2546
+ {formError ? (
2547
+ <Alert
2548
+ variant="destructive"
2549
+ className="rounded-md border-red-300 bg-red-50 p-4"
2550
+ >
2551
+ <AlertTitle className="text-sm">
2552
+ {t('verifyYourInput')}
2553
+ </AlertTitle>
2554
+ <AlertDescription className="text-sm">
2555
+ {formError}
2556
+ </AlertDescription>
2557
+ </Alert>
2558
+ ) : null}
2559
+
2560
+ <Button type="submit" className="w-full cursor-pointer">
2561
+ <Plus className="mr-1 h-4 w-4" />
2562
+ {t('buttonAddMenu')}
2563
+ </Button>
2564
+ </form>
2565
+ </Form>
2566
+ </SheetContent>
2567
+ </Sheet>
2568
+
2569
+ <AlertDialog open={openDeleteModal} onOpenChange={setOpenDeleteModal}>
2570
+ <AlertDialogContent>
2571
+ <AlertDialogHeader>
2572
+ <AlertDialogTitle>{t('dialogDeleteMenuTitle')}</AlertDialogTitle>
2573
+ <AlertDialogDescription>
2574
+ {t('dialogDeleteMenuDescription')}
2575
+ </AlertDialogDescription>
2576
+ </AlertDialogHeader>
2577
+ <AlertDialogFooter>
2578
+ <AlertDialogCancel>{t('deleteMenuCancel')}</AlertDialogCancel>
2579
+ <AlertDialogAction onClick={onDelete}>
2580
+ {t('deleteMenuConfirm')}
2581
+ </AlertDialogAction>
2582
+ </AlertDialogFooter>
2583
+ </AlertDialogContent>
2584
+ </AlertDialog>
2585
+
2586
+ <Drawer open={isTreeOpen} onOpenChange={setIsTreeOpen} direction="left">
2587
+ <DrawerContent className="max-w-full sm:max-w-md">
2588
+ <DrawerHeader>
2589
+ <DrawerTitle>{t('treeWorkspaceTitle')}</DrawerTitle>
2590
+ <DrawerDescription>
2591
+ {t('treeWorkspaceDescription')}
2592
+ </DrawerDescription>
2593
+ </DrawerHeader>
2594
+
2595
+ <div className="px-4 pb-4">
2596
+ <MenuTreeWorkspace
2597
+ menus={(allMenus ?? []) as TreeNode[]}
2598
+ searchQuery={searchQuery}
2599
+ selectedId={editingMenu?.id ?? null}
2600
+ onSelect={async (menuId) => {
2601
+ await handleEdit({ id: menuId });
2602
+ setIsTreeOpen(false);
2603
+ }}
2604
+ onSaved={async () => {
2605
+ await refreshWorkspace(editingMenu?.id ?? null);
2606
+ }}
2607
+ onAddSubmenu={(menuId) => {
2608
+ setIsTreeOpen(false);
2609
+ openCreateMenu(menuId);
2610
+ }}
2611
+ onDuplicate={handleDuplicateMenu}
2612
+ onDelete={requestDeleteMenu}
2613
+ onMoveToRoot={handleMoveToRoot}
2614
+ />
2615
+ </div>
2616
+ </DrawerContent>
2617
+ </Drawer>
1445
2618
  </Page>
1446
2619
  );
1447
2620
  }