@hed-hog/core 0.0.302 → 0.0.304
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/menu/menu.service.d.ts +1 -0
- package/dist/menu/menu.service.d.ts.map +1 -1
- package/dist/menu/menu.service.js +65 -0
- package/dist/menu/menu.service.js.map +1 -1
- package/hedhog/data/mail.yaml +147 -0
- package/hedhog/frontend/app/menu/page.tsx.ejs +1924 -751
- package/hedhog/frontend/app/roles/menus.tsx.ejs +45 -28
- package/hedhog/frontend/app/roles/routes.tsx.ejs +21 -16
- package/hedhog/frontend/app/roles/users.tsx.ejs +22 -14
- package/hedhog/frontend/app/users/permissions.tsx.ejs +7 -9
- package/hedhog/frontend/messages/en.json +28 -1
- package/hedhog/frontend/messages/pt.json +28 -1
- package/hedhog/table/role_menu.yaml +0 -2
- package/package.json +3 -3
- package/src/index.ts +10 -7
- package/src/menu/menu.service.ts +100 -5
|
@@ -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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
} from '@/components/ui/
|
|
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((
|
|
140
|
-
.map((
|
|
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
|
-
<
|
|
175
|
-
|
|
176
|
-
<
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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="
|
|
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
|
|
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-
|
|
287
|
+
<div className="space-y-1.5">
|
|
196
288
|
{roles.map((role) => {
|
|
197
|
-
const assigned =
|
|
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={
|
|
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
|
|
303
|
+
<div className="flex min-w-0 flex-1 items-center gap-2.5">
|
|
210
304
|
<div
|
|
211
|
-
className={
|
|
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={
|
|
311
|
+
className={cn(
|
|
312
|
+
'h-4 w-4',
|
|
217
313
|
assigned ? 'text-primary' : 'text-muted-foreground'
|
|
218
|
-
}
|
|
314
|
+
)}
|
|
219
315
|
/>
|
|
220
316
|
</div>
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
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((
|
|
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
|
-
|
|
289
|
-
|
|
478
|
+
|
|
479
|
+
const sortByOrder = (nodes: TreeNode[]): TreeNode[] =>
|
|
480
|
+
nodes
|
|
290
481
|
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
|
|
291
|
-
.map((
|
|
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
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
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
|
-
|
|
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((
|
|
321
|
-
if (
|
|
322
|
-
return { ...
|
|
519
|
+
return nodes.map((item) => {
|
|
520
|
+
if (item.id === targetId) {
|
|
521
|
+
return { ...item, children: [...item.children, node] };
|
|
323
522
|
}
|
|
523
|
+
|
|
324
524
|
return {
|
|
325
|
-
...
|
|
326
|
-
children: insertIntoTree(
|
|
525
|
+
...item,
|
|
526
|
+
children: insertIntoTree(item.children, node, targetId, position),
|
|
327
527
|
};
|
|
328
528
|
});
|
|
329
529
|
}
|
|
330
530
|
|
|
331
|
-
const
|
|
332
|
-
|
|
531
|
+
const targetIndex = nodes.findIndex((item) => item.id === targetId);
|
|
532
|
+
|
|
533
|
+
if (targetIndex !== -1) {
|
|
333
534
|
const result = [...nodes];
|
|
334
|
-
result.splice(
|
|
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((
|
|
339
|
-
...
|
|
340
|
-
children: insertIntoTree(
|
|
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 = (
|
|
350
|
-
for (const
|
|
351
|
-
if (
|
|
352
|
-
|
|
353
|
-
|
|
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
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
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((
|
|
369
|
-
siblings.forEach((
|
|
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
|
|
377
|
-
|
|
378
|
-
|
|
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
|
-
|
|
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
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
}, [
|
|
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:
|
|
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
|
-
|
|
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
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
{
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
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
|
-
<
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
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
|
-
|
|
1174
|
+
filteredTree.map((node) => renderNode(node, null, 0))
|
|
594
1175
|
)}
|
|
595
1176
|
</div>
|
|
596
|
-
</
|
|
597
|
-
</
|
|
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:
|
|
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:
|
|
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)
|
|
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 (
|
|
729
|
-
|
|
730
|
-
editForm.setValue('name', localeData?.name ?? '');
|
|
1417
|
+
if (editingMenuId == null) {
|
|
1418
|
+
return;
|
|
731
1419
|
}
|
|
732
|
-
|
|
1420
|
+
|
|
1421
|
+
setSelectedLocale(currentLocaleCode);
|
|
1422
|
+
}, [currentLocaleCode, editingMenuId]);
|
|
733
1423
|
|
|
734
1424
|
useEffect(() => {
|
|
735
|
-
if (editingMenu) {
|
|
736
|
-
|
|
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
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
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
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
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
|
-
|
|
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
|
-
|
|
797
|
-
|
|
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)
|
|
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
|
|
861
|
-
|
|
862
|
-
|
|
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:
|
|
1638
|
+
url: '/menu',
|
|
876
1639
|
method: 'DELETE',
|
|
877
|
-
data: { ids: [Number(editingMenu
|
|
1640
|
+
data: { ids: [Number(editingMenu.id)] },
|
|
878
1641
|
});
|
|
879
|
-
|
|
1642
|
+
|
|
880
1643
|
setOpenDeleteModal(false);
|
|
881
1644
|
setEditingMenu(null);
|
|
882
1645
|
setEditFormError(null);
|
|
883
1646
|
toast.success(t('menuDeletedSuccess'));
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
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
|
|
895
|
-
|
|
896
|
-
|
|
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: () =>
|
|
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
|
-
<
|
|
921
|
-
|
|
922
|
-
|
|
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:
|
|
927
|
-
|
|
928
|
-
|
|
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
|
-
<
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
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="
|
|
942
|
-
|
|
943
|
-
<div
|
|
944
|
-
|
|
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-${
|
|
947
|
-
className="
|
|
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={() =>
|
|
2287
|
+
onAction={() => openCreateMenu()}
|
|
968
2288
|
/>
|
|
969
2289
|
) : (
|
|
970
|
-
<div className="grid
|
|
971
|
-
{menusResponse
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
<
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
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
|
-
|
|
1006
|
-
|
|
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=
|
|
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
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
2374
|
+
<ShieldCheck className="mr-1 h-3 w-3" />
|
|
2375
|
+
{roleCount}
|
|
2376
|
+
</TooltipBadge>
|
|
2377
|
+
</div>
|
|
1013
2378
|
</div>
|
|
1014
2379
|
</div>
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
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
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
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
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
<
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
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="
|
|
2477
|
+
name="icon"
|
|
1059
2478
|
render={({ field }) => (
|
|
1060
2479
|
<FormItem>
|
|
1061
|
-
<FormLabel>{t('
|
|
2480
|
+
<FormLabel>{t('formIconLabel')}</FormLabel>
|
|
1062
2481
|
<FormControl>
|
|
1063
2482
|
<Input
|
|
1064
|
-
placeholder={t('
|
|
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="
|
|
2494
|
+
name="order"
|
|
1075
2495
|
render={({ field }) => (
|
|
1076
2496
|
<FormItem>
|
|
1077
|
-
<FormLabel>{t('
|
|
2497
|
+
<FormLabel>{t('formOrderLabel')}</FormLabel>
|
|
1078
2498
|
<FormControl>
|
|
1079
2499
|
<Input
|
|
1080
|
-
|
|
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
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
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
|
-
<
|
|
1096
|
-
|
|
1097
|
-
|
|
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
|
-
</
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
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
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
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
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
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
|
}
|