@hed-hog/core 0.0.213 → 0.0.214

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.
@@ -0,0 +1,1441 @@
1
+ 'use client';
2
+
3
+ import {
4
+ PageHeader,
5
+ PaginationFooter,
6
+ SearchBar,
7
+ StatsCards,
8
+ } from '@/components/entity-list';
9
+ import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
10
+ import { Badge } from '@/components/ui/badge';
11
+ import { Button } from '@/components/ui/button';
12
+ import {
13
+ Card,
14
+ CardDescription,
15
+ CardHeader,
16
+ CardTitle,
17
+ } from '@/components/ui/card';
18
+ import {
19
+ Dialog,
20
+ DialogContent,
21
+ DialogDescription,
22
+ DialogHeader,
23
+ DialogTitle,
24
+ } from '@/components/ui/dialog';
25
+ import {
26
+ Form,
27
+ FormControl,
28
+ FormField,
29
+ FormItem,
30
+ FormLabel,
31
+ FormMessage,
32
+ } from '@/components/ui/form';
33
+ import { Input } from '@/components/ui/input';
34
+ import { Label } from '@/components/ui/label';
35
+ import {
36
+ Select,
37
+ SelectContent,
38
+ SelectItem,
39
+ SelectTrigger,
40
+ SelectValue,
41
+ } from '@/components/ui/select';
42
+ import {
43
+ Sheet,
44
+ SheetContent,
45
+ SheetDescription,
46
+ SheetHeader,
47
+ SheetTitle,
48
+ } from '@/components/ui/sheet';
49
+ import { Switch } from '@/components/ui/switch';
50
+ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
51
+ import { useApp, useQuery } from '@hed-hog/next-app-provider';
52
+ import { zodResolver } from '@hookform/resolvers/zod';
53
+ import {
54
+ GitBranch,
55
+ Link,
56
+ Loader2,
57
+ Menu,
58
+ Plus,
59
+ ShieldCheck,
60
+ Trash2,
61
+ } from 'lucide-react';
62
+ import { useTranslations } from 'next-intl';
63
+ import React, { useEffect, useState } from 'react';
64
+ import { useForm } from 'react-hook-form';
65
+ import { toast } from 'sonner';
66
+ import { z } from 'zod';
67
+
68
+ type PaginatedResponse<T> = {
69
+ data: T[];
70
+ total: number;
71
+ page: number;
72
+ pageSize: number;
73
+ };
74
+
75
+ type RoleLocale = {
76
+ name: string;
77
+ description?: string;
78
+ };
79
+
80
+ type RoleItem = {
81
+ id: number;
82
+ name?: string;
83
+ role_locale?: RoleLocale[];
84
+ role_menu?: Array<{ role_id: number; menu_id: number }>;
85
+ };
86
+
87
+ type MenuRolesSectionProps = {
88
+ menuId: number;
89
+ };
90
+
91
+ function MenuRolesSection({ menuId }: MenuRolesSectionProps) {
92
+ const t = useTranslations('core.MenuPage');
93
+ const { request, currentLocaleCode } = useApp();
94
+ const [togglingRoleId, setTogglingRoleId] = useState<number | null>(null);
95
+
96
+ const {
97
+ data: rolesData,
98
+ isLoading,
99
+ refetch,
100
+ } = useQuery<PaginatedResponse<RoleItem>>({
101
+ queryKey: ['menu-roles', menuId, currentLocaleCode],
102
+ queryFn: async () => {
103
+ const response = await request<PaginatedResponse<RoleItem>>({
104
+ url: `/menu/${menuId}/role?pageSize=10000`,
105
+ method: 'GET',
106
+ });
107
+ return response.data;
108
+ },
109
+ enabled: !!menuId,
110
+ });
111
+
112
+ const roles = rolesData?.data ?? [];
113
+
114
+ const isRoleAssigned = (role: RoleItem) =>
115
+ !!(role.role_menu && role.role_menu.length > 0);
116
+
117
+ const getRoleName = (role: RoleItem) =>
118
+ role.role_locale?.[0]?.name ?? role.name ?? String(role.id);
119
+
120
+ const getRoleDescription = (role: RoleItem) =>
121
+ role.role_locale?.[0]?.description ?? '';
122
+
123
+ const handleToggle = async (roleId: number, currentlyAssigned: boolean) => {
124
+ setTogglingRoleId(roleId);
125
+ try {
126
+ const currentIds = roles
127
+ .filter((r) => isRoleAssigned(r))
128
+ .map((r) => r.id);
129
+
130
+ const newIds = currentlyAssigned
131
+ ? currentIds.filter((id) => id !== roleId)
132
+ : [...new Set([...currentIds, roleId])];
133
+
134
+ await request({
135
+ url: `/menu/${menuId}/role`,
136
+ method: 'PATCH',
137
+ data: { ids: newIds },
138
+ });
139
+
140
+ toast.success(currentlyAssigned ? t('roleRemoved') : t('roleAssigned'));
141
+ await refetch();
142
+ } catch {
143
+ toast.error(t('errorAssigningRole'));
144
+ } finally {
145
+ setTogglingRoleId(null);
146
+ }
147
+ };
148
+
149
+ if (isLoading) {
150
+ return (
151
+ <div className="flex items-center justify-center py-8">
152
+ <Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
153
+ <span className="ml-2 text-sm text-muted-foreground">
154
+ {t('loadingRoles')}
155
+ </span>
156
+ </div>
157
+ );
158
+ }
159
+
160
+ if (!roles.length) {
161
+ return (
162
+ <div className="border border-dashed rounded-lg p-8 flex flex-col items-center justify-center">
163
+ <ShieldCheck className="h-10 w-10 text-muted-foreground mb-3" />
164
+ <p className="text-sm font-medium text-center text-muted-foreground">
165
+ {t('noRolesFound')}
166
+ </p>
167
+ </div>
168
+ );
169
+ }
170
+
171
+ return (
172
+ <div className="space-y-3">
173
+ <div>
174
+ <h4 className="text-sm font-semibold flex items-center gap-2">
175
+ <ShieldCheck className="h-4 w-4" />
176
+ {t('rolesTitle')}
177
+ </h4>
178
+ <p className="text-xs text-muted-foreground mt-1">
179
+ {t('rolesDescription')}
180
+ </p>
181
+ </div>
182
+
183
+ <div className="space-y-2">
184
+ {roles.map((role) => {
185
+ const assigned = isRoleAssigned(role);
186
+ const toggling = togglingRoleId === role.id;
187
+
188
+ return (
189
+ <div
190
+ key={role.id}
191
+ className={`flex items-center justify-between p-3 rounded-md border transition-all ${
192
+ assigned
193
+ ? 'border-primary/50 bg-primary/5'
194
+ : 'border-border hover:border-primary/30'
195
+ }`}
196
+ >
197
+ <div className="flex items-center gap-3 flex-1 min-w-0">
198
+ <div
199
+ className={`rounded-md p-2 shrink-0 ${
200
+ assigned ? 'bg-primary/10' : 'bg-muted'
201
+ }`}
202
+ >
203
+ <ShieldCheck
204
+ className={`h-4 w-4 ${
205
+ assigned ? 'text-primary' : 'text-muted-foreground'
206
+ }`}
207
+ />
208
+ </div>
209
+ <div className="flex-1 min-w-0">
210
+ <Label
211
+ htmlFor={`role-${role.id}`}
212
+ className="text-sm font-medium cursor-pointer"
213
+ >
214
+ {getRoleName(role)}
215
+ </Label>
216
+ {getRoleDescription(role) && (
217
+ <p className="text-xs text-muted-foreground mt-0.5 truncate">
218
+ {getRoleDescription(role)}
219
+ </p>
220
+ )}
221
+ </div>
222
+ </div>
223
+ <Switch
224
+ id={`role-${role.id}`}
225
+ checked={assigned}
226
+ disabled={toggling}
227
+ onCheckedChange={() => handleToggle(role.id, assigned)}
228
+ className="ml-4 shrink-0"
229
+ />
230
+ </div>
231
+ );
232
+ })}
233
+ </div>
234
+ </div>
235
+ );
236
+ }
237
+
238
+ type TreeNode = {
239
+ id: number;
240
+ menu_id: number | null;
241
+ slug: string;
242
+ url: string;
243
+ icon: string | null;
244
+ order: number | null;
245
+ name?: string;
246
+ menu_locale?: Array<{ name?: string; locale?: { code: string } }>;
247
+ children: TreeNode[];
248
+ };
249
+
250
+ type MenuTreeDialogProps = {
251
+ open: boolean;
252
+ onClose: () => void;
253
+ menus: TreeNode[];
254
+ onSaved: () => void;
255
+ };
256
+
257
+ type DropPosition = 'before' | 'inside' | 'after';
258
+
259
+ type DropTarget = {
260
+ id: number;
261
+ parentId: number | null;
262
+ position: DropPosition;
263
+ } | null;
264
+
265
+ function buildTree(flat: TreeNode[]): TreeNode[] {
266
+ const map = new Map<number, TreeNode>();
267
+ flat.forEach((m) => map.set(m.id, { ...m, children: [] }));
268
+ const roots: TreeNode[] = [];
269
+ map.forEach((node) => {
270
+ if (node.menu_id != null && map.has(node.menu_id)) {
271
+ map.get(node.menu_id)!.children.push(node);
272
+ } else {
273
+ roots.push(node);
274
+ }
275
+ });
276
+ const sortByOrder = (arr: TreeNode[]): TreeNode[] =>
277
+ arr
278
+ .sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
279
+ .map((n) => ({ ...n, children: sortByOrder(n.children) }));
280
+ return sortByOrder(roots);
281
+ }
282
+
283
+ function removeNodeFromTree(
284
+ nodes: TreeNode[],
285
+ id: number
286
+ ): { tree: TreeNode[]; removed: TreeNode | null } {
287
+ let removed: TreeNode | null = null;
288
+ const process = (arr: TreeNode[]): TreeNode[] => {
289
+ const filtered = arr.filter((n) => {
290
+ if (n.id === id) {
291
+ removed = n;
292
+ return false;
293
+ }
294
+ return true;
295
+ });
296
+ return filtered.map((n) => ({ ...n, children: process(n.children) }));
297
+ };
298
+ return { tree: process(nodes), removed };
299
+ }
300
+
301
+ function insertIntoTree(
302
+ nodes: TreeNode[],
303
+ node: TreeNode,
304
+ targetId: number,
305
+ position: DropPosition
306
+ ): TreeNode[] {
307
+ if (position === 'inside') {
308
+ return nodes.map((n) => {
309
+ if (n.id === targetId) {
310
+ return { ...n, children: [...n.children, node] };
311
+ }
312
+ return {
313
+ ...n,
314
+ children: insertIntoTree(n.children, node, targetId, position),
315
+ };
316
+ });
317
+ }
318
+
319
+ const idx = nodes.findIndex((n) => n.id === targetId);
320
+ if (idx !== -1) {
321
+ const result = [...nodes];
322
+ result.splice(position === 'before' ? idx : idx + 1, 0, node);
323
+ return result;
324
+ }
325
+
326
+ return nodes.map((n) => ({
327
+ ...n,
328
+ children: insertIntoTree(n.children, node, targetId, position),
329
+ }));
330
+ }
331
+
332
+ function isDescendantOf(
333
+ nodes: TreeNode[],
334
+ ancestorId: number,
335
+ candidateId: number
336
+ ): boolean {
337
+ const findAncestor = (arr: TreeNode[]): TreeNode | null => {
338
+ for (const n of arr) {
339
+ if (n.id === ancestorId) return n;
340
+ const found = findAncestor(n.children);
341
+ if (found) return found;
342
+ }
343
+ return null;
344
+ };
345
+ const ancestor = findAncestor(nodes);
346
+ if (!ancestor) return false;
347
+ const searchInChildren = (children: TreeNode[]): boolean =>
348
+ children.some((c) => c.id === candidateId || searchInChildren(c.children));
349
+ return searchInChildren(ancestor.children);
350
+ }
351
+
352
+ function collectOrderGroups(nodes: TreeNode[]): { ids: number[] }[] {
353
+ const groups: { ids: number[] }[] = [];
354
+ const traverse = (siblings: TreeNode[]) => {
355
+ if (siblings.length > 0) {
356
+ groups.push({ ids: siblings.map((n) => n.id) });
357
+ siblings.forEach((n) => traverse(n.children));
358
+ }
359
+ };
360
+ traverse(nodes);
361
+ return groups;
362
+ }
363
+
364
+ function MenuTreeDialog({
365
+ open,
366
+ onClose,
367
+ menus,
368
+ onSaved,
369
+ }: MenuTreeDialogProps) {
370
+ const t = useTranslations('core.MenuPage');
371
+ const { request } = useApp();
372
+
373
+ const [tree, setTree] = useState<TreeNode[]>([]);
374
+ const [saving, setSaving] = useState(false);
375
+ const [dragging, setDragging] = useState<{
376
+ id: number;
377
+ parentId: number | null;
378
+ } | null>(null);
379
+ const [dropTarget, setDropTarget] = useState<DropTarget>(null);
380
+
381
+ useEffect(() => {
382
+ if (open) {
383
+ setTree(buildTree(menus as TreeNode[]));
384
+ }
385
+ }, [open, menus]);
386
+
387
+ const getDisplayName = (node: TreeNode) =>
388
+ node.menu_locale?.[0]?.name ?? node.name ?? node.slug;
389
+
390
+ const handleDrop = async (target: DropTarget) => {
391
+ if (!target || !dragging || dragging.id === target.id) {
392
+ setDragging(null);
393
+ setDropTarget(null);
394
+ return;
395
+ }
396
+
397
+ if (
398
+ target.position === 'inside' &&
399
+ isDescendantOf(tree, dragging.id, target.id)
400
+ ) {
401
+ setDragging(null);
402
+ setDropTarget(null);
403
+ return;
404
+ }
405
+
406
+ const newParentId =
407
+ target.position === 'inside' ? target.id : target.parentId;
408
+ const oldParentId = dragging.parentId;
409
+ const draggedId = dragging.id;
410
+
411
+ const { tree: treeWithout, removed } = removeNodeFromTree(
412
+ tree,
413
+ dragging.id
414
+ );
415
+ if (!removed) {
416
+ setDragging(null);
417
+ setDropTarget(null);
418
+ return;
419
+ }
420
+
421
+ const newTree = insertIntoTree(
422
+ treeWithout,
423
+ removed,
424
+ target.id,
425
+ target.position
426
+ );
427
+ setTree(newTree);
428
+ setDragging(null);
429
+ setDropTarget(null);
430
+
431
+ setSaving(true);
432
+ try {
433
+ if (oldParentId !== newParentId) {
434
+ await request({
435
+ url: `/menu/${draggedId}`,
436
+ method: 'PATCH',
437
+ data: { menu_id: newParentId },
438
+ });
439
+ }
440
+
441
+ const groups = collectOrderGroups(newTree);
442
+ await Promise.all(
443
+ groups.map(({ ids }) =>
444
+ request({
445
+ url: '/menu/order',
446
+ method: 'PATCH',
447
+ data: { ids },
448
+ })
449
+ )
450
+ );
451
+
452
+ toast.success(t('treeOrderSaved'));
453
+ onSaved();
454
+ } catch {
455
+ toast.error(t('treeOrderError'));
456
+ setTree(buildTree(menus as TreeNode[]));
457
+ } finally {
458
+ setSaving(false);
459
+ }
460
+ };
461
+
462
+ const renderNode = (
463
+ node: TreeNode,
464
+ parentId: number | null,
465
+ depth = 0
466
+ ): React.ReactNode => {
467
+ const isDraggingThis = dragging?.id === node.id;
468
+ const isTarget = dropTarget?.id === node.id;
469
+ const isBefore = isTarget && dropTarget?.position === 'before';
470
+ const isAfter = isTarget && dropTarget?.position === 'after';
471
+ const isInside = isTarget && dropTarget?.position === 'inside';
472
+
473
+ return (
474
+ <div key={node.id}>
475
+ {isBefore && <div className="h-0.5 bg-primary rounded mx-2 my-0.5" />}
476
+ <div
477
+ draggable
478
+ onDragStart={(e) => {
479
+ e.dataTransfer.effectAllowed = 'move';
480
+ setDragging({ id: node.id, parentId });
481
+ }}
482
+ onDragEnd={() => {
483
+ setDragging(null);
484
+ setDropTarget(null);
485
+ }}
486
+ onDragOver={(e) => {
487
+ e.preventDefault();
488
+ e.stopPropagation();
489
+ if (!dragging || dragging.id === node.id) return;
490
+ if (isDescendantOf(tree, dragging.id, node.id)) return;
491
+
492
+ const rect = (
493
+ e.currentTarget as HTMLDivElement
494
+ ).getBoundingClientRect();
495
+ const ratio = (e.clientY - rect.top) / rect.height;
496
+
497
+ let pos: DropPosition;
498
+ if (ratio < 0.25) pos = 'before';
499
+ else if (ratio > 0.75) pos = 'after';
500
+ else pos = 'inside';
501
+
502
+ setDropTarget({ id: node.id, parentId, position: pos });
503
+ }}
504
+ onDragLeave={(e) => {
505
+ if (!e.currentTarget.contains(e.relatedTarget as Node)) {
506
+ setDropTarget((prev) => (prev?.id === node.id ? null : prev));
507
+ }
508
+ }}
509
+ onDrop={(e) => {
510
+ e.preventDefault();
511
+ e.stopPropagation();
512
+ handleDrop(dropTarget);
513
+ }}
514
+ style={{ paddingLeft: `${depth * 20}px` }}
515
+ className={[
516
+ 'flex items-center gap-2 px-3 py-2 rounded-md cursor-grab select-none transition-all',
517
+ isDraggingThis ? 'opacity-40' : 'opacity-100',
518
+ isInside ? 'ring-2 ring-primary bg-primary/10' : 'hover:bg-muted',
519
+ ].join(' ')}
520
+ >
521
+ <span className="text-muted-foreground cursor-grab" title="Arrastar">
522
+ <svg width="14" height="14" viewBox="0 0 14 14" fill="currentColor">
523
+ <circle cx="4" cy="3" r="1.2" />
524
+ <circle cx="10" cy="3" r="1.2" />
525
+ <circle cx="4" cy="7" r="1.2" />
526
+ <circle cx="10" cy="7" r="1.2" />
527
+ <circle cx="4" cy="11" r="1.2" />
528
+ <circle cx="10" cy="11" r="1.2" />
529
+ </svg>
530
+ </span>
531
+ <Menu className="h-4 w-4 text-muted-foreground shrink-0" />
532
+ <div className="flex-1 min-w-0">
533
+ <span className="text-sm font-medium">{getDisplayName(node)}</span>
534
+ <span className="text-xs text-muted-foreground ml-2">
535
+ {node.url}
536
+ </span>
537
+ </div>
538
+ {node.children.length > 0 && (
539
+ <span className="text-xs text-muted-foreground shrink-0">
540
+ {node.children.length}{' '}
541
+ {node.children.length === 1 ? t('treeChild') : t('treeChildren')}
542
+ </span>
543
+ )}
544
+ </div>
545
+ {isAfter && <div className="h-0.5 bg-primary rounded mx-2 my-0.5" />}
546
+ {node.children.length > 0 && (
547
+ <div className="ml-2 border-l border-border/50 pl-1 mt-0.5 mb-0.5 space-y-0.5">
548
+ {node.children.map((child) =>
549
+ renderNode(child, node.id, depth + 1)
550
+ )}
551
+ </div>
552
+ )}
553
+ </div>
554
+ );
555
+ };
556
+
557
+ return (
558
+ <Dialog
559
+ open={open}
560
+ onOpenChange={(v) => {
561
+ if (!v) onClose();
562
+ }}
563
+ >
564
+ <DialogContent className="sm:max-w-2xl max-h-[85vh] flex flex-col">
565
+ <DialogHeader>
566
+ <DialogTitle className="flex items-center gap-2">
567
+ {t('treeDialogTitle')}
568
+ {saving && (
569
+ <Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
570
+ )}
571
+ </DialogTitle>
572
+ <DialogDescription>{t('treeDialogDescription')}</DialogDescription>
573
+ </DialogHeader>
574
+
575
+ <div className="flex-1 overflow-y-auto border rounded-lg p-2 space-y-0.5 min-h-0">
576
+ {tree.length === 0 ? (
577
+ <div className="flex items-center justify-center py-12 text-muted-foreground text-sm">
578
+ {t('noMenusFound')}
579
+ </div>
580
+ ) : (
581
+ tree.map((node) => renderNode(node, null, 0))
582
+ )}
583
+ </div>
584
+ </DialogContent>
585
+ </Dialog>
586
+ );
587
+ }
588
+
589
+ type MenuLocale = {
590
+ name: string;
591
+ };
592
+
593
+ type MenuItem = {
594
+ id: number;
595
+ menu_id: number | null;
596
+ slug: string;
597
+ url: string;
598
+ icon: string | null;
599
+ order: number | null;
600
+ menu_locale?: Array<{
601
+ name?: string;
602
+ locale?: { code: string };
603
+ }>;
604
+ name?: string;
605
+ locale?: Record<string, MenuLocale>;
606
+ };
607
+
608
+ type MenuStats = {
609
+ total: number;
610
+ };
611
+
612
+ const NO_PARENT = '__none__';
613
+
614
+ export default function MenuPage() {
615
+ const t = useTranslations('core.MenuPage');
616
+ const { request, currentLocaleCode, locales } = useApp();
617
+
618
+ const [searchQuery, setSearchQuery] = useState('');
619
+ const [isDialogOpen, setIsDialogOpen] = useState(false);
620
+ const [isTreeOpen, setIsTreeOpen] = useState(false);
621
+ const [editingMenu, setEditingMenu] = useState<MenuItem | null>(null);
622
+ const [formError, setFormError] = useState<string | null>(null);
623
+ const [editFormError, setEditFormError] = useState<string | null>(null);
624
+ const [openDeleteModal, setOpenDeleteModal] = useState(false);
625
+ const [selectedLocale, setSelectedLocale] = useState(currentLocaleCode);
626
+
627
+ const [page, setPage] = useState(1);
628
+ const [pageSize, setPageSize] = useState(12);
629
+
630
+ const { data: allMenus } = useQuery<MenuItem[]>({
631
+ queryKey: ['menus-all', currentLocaleCode],
632
+ queryFn: async () => {
633
+ const response = await request<MenuItem[]>({
634
+ url: `/menu/all`,
635
+ method: 'GET',
636
+ });
637
+ return response.data;
638
+ },
639
+ enabled: true,
640
+ });
641
+
642
+ const { data: menuStats } = useQuery<MenuStats>({
643
+ queryKey: ['menus-stats'],
644
+ queryFn: async () => {
645
+ const response = await request<MenuStats>({
646
+ url: `/menu/stats`,
647
+ method: 'GET',
648
+ });
649
+ return response.data;
650
+ },
651
+ });
652
+
653
+ const {
654
+ data: menusResponse,
655
+ isLoading,
656
+ refetch,
657
+ } = useQuery<PaginatedResponse<MenuItem>>({
658
+ queryKey: ['menus', page, pageSize, searchQuery, currentLocaleCode],
659
+ queryFn: async () => {
660
+ const params = new URLSearchParams();
661
+ params.set('page', String(page));
662
+ params.set('pageSize', String(pageSize));
663
+ if (searchQuery) params.set('search', searchQuery);
664
+
665
+ const response = await request<PaginatedResponse<MenuItem>>({
666
+ url: `/menu?${params.toString()}`,
667
+ method: 'GET',
668
+ });
669
+ return response.data;
670
+ },
671
+ });
672
+
673
+ const addMenuSchema = z.object({
674
+ slug: z.string().min(2, t('errorSlug')),
675
+ url: z.string().min(1, t('errorUrl')),
676
+ name: z.string().optional(),
677
+ icon: z.string().optional(),
678
+ order: z.coerce.number().int().min(1).optional(),
679
+ menu_id: z.string().optional(),
680
+ });
681
+
682
+ const editMenuSchema = z.object({
683
+ slug: z.string().min(2, t('errorSlug')),
684
+ url: z.string().min(1, t('errorUrl')),
685
+ icon: z.string().optional(),
686
+ order: z.coerce.number().int().min(1).optional(),
687
+ menu_id: z.string().optional(),
688
+ name: z.string().optional(),
689
+ });
690
+
691
+ const form = useForm<z.infer<typeof addMenuSchema>>({
692
+ resolver: zodResolver(addMenuSchema),
693
+ defaultValues: {
694
+ slug: '',
695
+ url: '',
696
+ name: '',
697
+ icon: '',
698
+ order: undefined,
699
+ menu_id: NO_PARENT,
700
+ },
701
+ });
702
+
703
+ const editForm = useForm<z.infer<typeof editMenuSchema>>({
704
+ resolver: zodResolver(editMenuSchema),
705
+ defaultValues: {
706
+ slug: '',
707
+ url: '',
708
+ icon: '',
709
+ order: undefined,
710
+ menu_id: NO_PARENT,
711
+ name: '',
712
+ },
713
+ });
714
+
715
+ useEffect(() => {
716
+ if (editingMenu) {
717
+ const localeData = editingMenu.locale?.[selectedLocale];
718
+ editForm.setValue('name', localeData?.name ?? '');
719
+ }
720
+ }, [selectedLocale, editingMenu?.id]);
721
+
722
+ useEffect(() => {
723
+ if (editingMenu) {
724
+ const localeData = editingMenu.locale?.[currentLocaleCode];
725
+ setSelectedLocale(currentLocaleCode);
726
+ editForm.reset({
727
+ slug: editingMenu.slug || '',
728
+ url: editingMenu.url || '',
729
+ icon: editingMenu.icon || '',
730
+ order: editingMenu.order ?? undefined,
731
+ menu_id: editingMenu.menu_id ? String(editingMenu.menu_id) : NO_PARENT,
732
+ name: localeData?.name || editingMenu.name || '',
733
+ });
734
+ }
735
+ }, [editingMenu?.id]);
736
+
737
+ const onSubmit = async (values: z.infer<typeof addMenuSchema>) => {
738
+ try {
739
+ const localePayload: Record<string, string> = {};
740
+ if (values.name) {
741
+ localePayload[currentLocaleCode] = values.name;
742
+ }
743
+
744
+ await request({
745
+ url: '/menu',
746
+ method: 'POST',
747
+ data: {
748
+ slug: values.slug,
749
+ url: values.url,
750
+ icon: values.icon || undefined,
751
+ order: values.order || undefined,
752
+ menu_id:
753
+ values.menu_id && values.menu_id !== NO_PARENT
754
+ ? Number(values.menu_id)
755
+ : undefined,
756
+ ...(Object.keys(localePayload).length > 0
757
+ ? { locale: localePayload }
758
+ : {}),
759
+ },
760
+ });
761
+
762
+ form.reset({
763
+ slug: '',
764
+ url: '',
765
+ name: '',
766
+ icon: '',
767
+ order: undefined,
768
+ menu_id: NO_PARENT,
769
+ });
770
+ refetch();
771
+ setIsDialogOpen(false);
772
+ setFormError(null);
773
+ toast.success(t('menuCreatedSuccess'));
774
+ } catch (err: any) {
775
+ const msg =
776
+ err?.response?.data?.message ||
777
+ err?.response?.data?.error ||
778
+ err?.message ||
779
+ t('serverError');
780
+ setFormError(String(msg));
781
+ }
782
+ };
783
+
784
+ const handleEdit = async (menu: MenuItem) => {
785
+ setEditFormError(null);
786
+
787
+ try {
788
+ const response = await request<MenuItem>({
789
+ url: `/menu/${menu.id}`,
790
+ method: 'GET',
791
+ });
792
+
793
+ const fullMenu = response.data;
794
+ const localeData: Record<string, MenuLocale> = {};
795
+
796
+ if (fullMenu.menu_locale && Array.isArray(fullMenu.menu_locale)) {
797
+ fullMenu.menu_locale.forEach((ml: any) => {
798
+ const localeCode = ml.locale?.code;
799
+ if (localeCode) {
800
+ localeData[localeCode] = { name: ml.name || '' };
801
+ }
802
+ });
803
+ }
804
+
805
+ locales?.forEach((locale: any) => {
806
+ if (!localeData[locale.code]) {
807
+ localeData[locale.code] = { name: '' };
808
+ }
809
+ });
810
+
811
+ setEditingMenu({ ...fullMenu, locale: localeData });
812
+ } catch (err) {
813
+ console.error('Error fetching menu:', err);
814
+ toast.error(t('serverError'));
815
+ }
816
+ };
817
+
818
+ const onEditSubmit = async (values: z.infer<typeof editMenuSchema>) => {
819
+ if (!editingMenu) return;
820
+
821
+ try {
822
+ const localePayload: Record<string, string> = {};
823
+ if (editingMenu.locale) {
824
+ Object.entries(editingMenu.locale).forEach(([code, data]) => {
825
+ localePayload[code] = data.name;
826
+ });
827
+ }
828
+ localePayload[selectedLocale] = values.name || '';
829
+
830
+ await request({
831
+ url: `/menu/${editingMenu.id}`,
832
+ method: 'PATCH',
833
+ data: {
834
+ slug: values.slug,
835
+ url: values.url,
836
+ icon: values.icon || undefined,
837
+ order: values.order || undefined,
838
+ menu_id:
839
+ values.menu_id && values.menu_id !== NO_PARENT
840
+ ? Number(values.menu_id)
841
+ : null,
842
+ locale: localePayload,
843
+ },
844
+ });
845
+
846
+ toast.success(t('menuUpdatedSuccess'));
847
+ setEditFormError(null);
848
+ await refetch();
849
+ setEditingMenu(null);
850
+ } catch (err: any) {
851
+ const msg =
852
+ err?.response?.data?.message ||
853
+ err?.response?.data?.error ||
854
+ err?.message ||
855
+ t('serverError');
856
+ setEditFormError(String(msg));
857
+ }
858
+ };
859
+
860
+ const onDelete = async () => {
861
+ try {
862
+ await request({
863
+ url: `/menu`,
864
+ method: 'DELETE',
865
+ data: { ids: [Number(editingMenu?.id)] },
866
+ });
867
+ refetch();
868
+ setOpenDeleteModal(false);
869
+ setEditingMenu(null);
870
+ setEditFormError(null);
871
+ toast.success(t('menuDeletedSuccess'));
872
+ } catch (err: any) {
873
+ const msg =
874
+ err?.response?.data?.message ||
875
+ err?.response?.data?.error ||
876
+ err?.message ||
877
+ t('serverError');
878
+ setEditFormError(String(msg));
879
+ }
880
+ };
881
+
882
+ const getMenuDisplayName = (menu: MenuItem) => menu.name || menu.slug;
883
+ const parentMenuOptions = (allMenus ?? []).filter(
884
+ (m) => m.id !== editingMenu?.id
885
+ );
886
+
887
+ return (
888
+ <div className="flex flex-col h-screen px-4">
889
+ <PageHeader
890
+ breadcrumbs={[{ label: 'Home', href: '/' }, { label: t('menus') }]}
891
+ actions={[
892
+ {
893
+ label: t('buttonViewTree'),
894
+ onClick: () => setIsTreeOpen(true),
895
+ variant: 'outline',
896
+ icon: <GitBranch className="h-4 w-4" />,
897
+ },
898
+ {
899
+ label: t('buttonAddMenu'),
900
+ onClick: () => setIsDialogOpen(true),
901
+ variant: 'default',
902
+ },
903
+ ]}
904
+ title={t('title')}
905
+ description={t('description')}
906
+ />
907
+
908
+ <StatsCards
909
+ className="sm:grid-cols-1 md:grid-cols-1 lg:grid-cols-1"
910
+ stats={[
911
+ {
912
+ title: t('totalMenus'),
913
+ value: String(menuStats?.total ?? 0),
914
+ icon: <Menu className="h-5 w-5" />,
915
+ iconBgColor: 'bg-blue-50',
916
+ iconColor: 'text-blue-600',
917
+ },
918
+ ]}
919
+ />
920
+
921
+ <SearchBar
922
+ searchQuery={searchQuery}
923
+ onSearchChange={setSearchQuery}
924
+ onSearch={() => refetch()}
925
+ placeholder={t('searchPlaceholder')}
926
+ className="mt-4"
927
+ />
928
+
929
+ <div className="flex-1 pt-4">
930
+ {isLoading && (
931
+ <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
932
+ {Array.from({ length: 3 }).map((_, i) => (
933
+ <Card
934
+ key={`skeleton-${i}`}
935
+ className="flex flex-col justify-between gap-2 rounded-2xl border border-border/60 bg-card p-4 shadow-sm animate-pulse"
936
+ >
937
+ <CardHeader className="p-0">
938
+ <div className="space-y-2">
939
+ <div className="h-4 w-40 rounded bg-muted" />
940
+ <div className="h-3 w-32 rounded bg-muted" />
941
+ </div>
942
+ </CardHeader>
943
+ </Card>
944
+ ))}
945
+ </div>
946
+ )}
947
+
948
+ {!isLoading &&
949
+ (!menusResponse?.data || menusResponse.data.length === 0) ? (
950
+ <p className="text-sm text-muted-foreground">{t('noMenusFound')}</p>
951
+ ) : (
952
+ <div className="grid gap-4 grid-cols-1 lg:grid-cols-2 xl:grid-cols-3">
953
+ {menusResponse?.data?.map((menu: MenuItem) => (
954
+ <Card
955
+ key={String(menu.id)}
956
+ onDoubleClick={() => handleEdit(menu)}
957
+ className="cursor-pointer rounded-md flex flex-col justify-between gap-2 border border-border/60 bg-card p-4 shadow-sm transition hover:border-primary"
958
+ >
959
+ <CardHeader className="flex items-start justify-between gap-4 p-0">
960
+ <div className="flex items-center gap-3 flex-1">
961
+ <div className="h-12 w-12 shrink-0 rounded-full bg-primary/10 flex items-center justify-center">
962
+ <Menu className="h-6 w-6 text-primary" />
963
+ </div>
964
+ <div className="flex-1 min-w-0">
965
+ <CardTitle className="text-sm font-semibold truncate">
966
+ {getMenuDisplayName(menu)}
967
+ </CardTitle>
968
+ <CardDescription className="text-xs text-muted-foreground flex items-center gap-1 mt-0.5">
969
+ <Link className="h-3 w-3" />
970
+ <span className="truncate">{menu.url}</span>
971
+ </CardDescription>
972
+ <div className="flex gap-1 mt-1 flex-wrap">
973
+ <Badge
974
+ variant="secondary"
975
+ className="text-xs px-1.5 py-0"
976
+ >
977
+ {menu.slug}
978
+ </Badge>
979
+ {menu.order != null && (
980
+ <Badge
981
+ variant="outline"
982
+ className="text-xs px-1.5 py-0"
983
+ >
984
+ #{menu.order}
985
+ </Badge>
986
+ )}
987
+ {menu.menu_id != null && (
988
+ <Badge
989
+ variant="outline"
990
+ className="text-xs px-1.5 py-0"
991
+ >
992
+ {t('subMenu')}
993
+ </Badge>
994
+ )}
995
+ </div>
996
+ </div>
997
+ </div>
998
+ <Button
999
+ variant="outline"
1000
+ size="sm"
1001
+ onClick={() => handleEdit(menu)}
1002
+ >
1003
+ {t('buttonEditMenu')}
1004
+ </Button>
1005
+ </CardHeader>
1006
+ </Card>
1007
+ ))}
1008
+ </div>
1009
+ )}
1010
+
1011
+ <div className="w-full border-t pt-2 mt-4">
1012
+ <PaginationFooter
1013
+ currentPage={page}
1014
+ pageSize={pageSize}
1015
+ totalItems={menusResponse?.total || 0}
1016
+ onPageChange={setPage}
1017
+ onPageSizeChange={(size) => {
1018
+ setPageSize(size);
1019
+ setPage(1);
1020
+ }}
1021
+ pageSizeOptions={[6, 12, 24, 48]}
1022
+ />
1023
+ </div>
1024
+
1025
+ <Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
1026
+ <DialogContent className="sm:max-w-lg">
1027
+ <DialogHeader>
1028
+ <DialogTitle>{t('dialogAddMenuTitle')}</DialogTitle>
1029
+ <DialogDescription>
1030
+ {t('dialogAddMenuDescription')}
1031
+ </DialogDescription>
1032
+ </DialogHeader>
1033
+ <div className="w-full border-t pt-1 mt-1" />
1034
+ <Form {...form}>
1035
+ <form
1036
+ onSubmit={form.handleSubmit(onSubmit)}
1037
+ className="space-y-4"
1038
+ >
1039
+ <FormField
1040
+ control={form.control}
1041
+ name="slug"
1042
+ render={({ field }) => (
1043
+ <FormItem>
1044
+ <FormLabel>{t('formSlugLabel')}</FormLabel>
1045
+ <FormControl>
1046
+ <Input
1047
+ placeholder={t('formSlugPlaceholder')}
1048
+ {...field}
1049
+ />
1050
+ </FormControl>
1051
+ <FormMessage />
1052
+ </FormItem>
1053
+ )}
1054
+ />
1055
+ <FormField
1056
+ control={form.control}
1057
+ name="url"
1058
+ render={({ field }) => (
1059
+ <FormItem>
1060
+ <FormLabel>{t('formUrlLabel')}</FormLabel>
1061
+ <FormControl>
1062
+ <Input
1063
+ placeholder={t('formUrlPlaceholder')}
1064
+ {...field}
1065
+ />
1066
+ </FormControl>
1067
+ <FormMessage />
1068
+ </FormItem>
1069
+ )}
1070
+ />
1071
+ <FormField
1072
+ control={form.control}
1073
+ name="name"
1074
+ render={({ field }) => (
1075
+ <FormItem>
1076
+ <FormLabel>{t('formNameLabel')}</FormLabel>
1077
+ <FormControl>
1078
+ <Input
1079
+ placeholder={t('formNamePlaceholder')}
1080
+ {...field}
1081
+ />
1082
+ </FormControl>
1083
+ <FormMessage />
1084
+ </FormItem>
1085
+ )}
1086
+ />
1087
+ <div className="grid grid-cols-2 gap-4">
1088
+ <FormField
1089
+ control={form.control}
1090
+ name="icon"
1091
+ render={({ field }) => (
1092
+ <FormItem>
1093
+ <FormLabel>{t('formIconLabel')}</FormLabel>
1094
+ <FormControl>
1095
+ <Input
1096
+ placeholder={t('formIconPlaceholder')}
1097
+ {...field}
1098
+ />
1099
+ </FormControl>
1100
+ <FormMessage />
1101
+ </FormItem>
1102
+ )}
1103
+ />
1104
+ <FormField
1105
+ control={form.control}
1106
+ name="order"
1107
+ render={({ field }) => (
1108
+ <FormItem>
1109
+ <FormLabel>{t('formOrderLabel')}</FormLabel>
1110
+ <FormControl>
1111
+ <Input
1112
+ type="number"
1113
+ min={1}
1114
+ placeholder={t('formOrderPlaceholder')}
1115
+ {...field}
1116
+ value={field.value ?? ''}
1117
+ />
1118
+ </FormControl>
1119
+ <FormMessage />
1120
+ </FormItem>
1121
+ )}
1122
+ />
1123
+ </div>
1124
+
1125
+ <FormField
1126
+ control={form.control}
1127
+ name="menu_id"
1128
+ render={({ field }) => (
1129
+ <FormItem>
1130
+ <FormLabel>{t('formParentMenuLabel')}</FormLabel>
1131
+ <Select
1132
+ value={field.value ?? NO_PARENT}
1133
+ onValueChange={field.onChange}
1134
+ >
1135
+ <FormControl>
1136
+ <SelectTrigger className="w-full">
1137
+ <SelectValue
1138
+ placeholder={t('formParentMenuPlaceholder')}
1139
+ />
1140
+ </SelectTrigger>
1141
+ </FormControl>
1142
+ <SelectContent className="w-full">
1143
+ <SelectItem value={NO_PARENT}>
1144
+ {t('formParentMenuNone')}
1145
+ </SelectItem>
1146
+ {(allMenus ?? []).map((m) => (
1147
+ <SelectItem key={m.id} value={String(m.id)}>
1148
+ {getMenuDisplayName(m)}
1149
+ </SelectItem>
1150
+ ))}
1151
+ </SelectContent>
1152
+ </Select>
1153
+ <FormMessage />
1154
+ </FormItem>
1155
+ )}
1156
+ />
1157
+
1158
+ {formError && (
1159
+ <Alert
1160
+ variant="destructive"
1161
+ className="border-red-300 bg-red-50 rounded-md p-4"
1162
+ >
1163
+ <AlertTitle className="text-sm">
1164
+ {t('verifyYourInput')}
1165
+ </AlertTitle>
1166
+ <AlertDescription className="text-sm">
1167
+ {formError}
1168
+ </AlertDescription>
1169
+ </Alert>
1170
+ )}
1171
+
1172
+ <Button type="submit" className="w-full">
1173
+ <Plus className="h-4 w-4 mr-1" />
1174
+ {t('buttonAddMenu')}
1175
+ </Button>
1176
+ </form>
1177
+ </Form>
1178
+ </DialogContent>
1179
+ </Dialog>
1180
+
1181
+ {editingMenu && (
1182
+ <Sheet open={!!editingMenu} onOpenChange={() => setEditingMenu(null)}>
1183
+ <SheetContent className="w-full sm:max-w-lg overflow-y-auto p-0">
1184
+ <SheetHeader className="px-6 pt-6 pb-4 border-b">
1185
+ <div className="flex items-start justify-between gap-3">
1186
+ <div className="min-w-0">
1187
+ <SheetTitle>{t('titleEditMenu')}</SheetTitle>
1188
+ <SheetDescription className="mt-0.5 truncate">
1189
+ {getMenuDisplayName(editingMenu)}
1190
+ </SheetDescription>
1191
+ </div>
1192
+ <Select
1193
+ value={selectedLocale}
1194
+ onValueChange={(value) => {
1195
+ const currentName = editForm.getValues('name');
1196
+ setEditingMenu((prev) => {
1197
+ if (!prev) return prev;
1198
+ return {
1199
+ ...prev,
1200
+ locale: {
1201
+ ...prev.locale,
1202
+ [selectedLocale]: { name: currentName ?? '' },
1203
+ },
1204
+ };
1205
+ });
1206
+ setSelectedLocale(value);
1207
+ }}
1208
+ >
1209
+ <SelectTrigger className="w-[180px] h-8 shrink-0">
1210
+ <SelectValue />
1211
+ </SelectTrigger>
1212
+ <SelectContent>
1213
+ {(locales ?? []).map((locale: any) => (
1214
+ <SelectItem key={locale.code} value={locale.code}>
1215
+ {locale.name}
1216
+ </SelectItem>
1217
+ ))}
1218
+ </SelectContent>
1219
+ </Select>
1220
+ </div>
1221
+ </SheetHeader>
1222
+
1223
+ <div className="px-6 py-4">
1224
+ <Tabs defaultValue="basic-info">
1225
+ <TabsList className="w-full">
1226
+ <TabsTrigger value="basic-info" className="flex-1">
1227
+ {t('tabBasicInfo')}
1228
+ </TabsTrigger>
1229
+ <TabsTrigger value="roles" className="flex-1">
1230
+ {t('tabRoles')}
1231
+ </TabsTrigger>
1232
+ </TabsList>
1233
+
1234
+ <TabsContent value="basic-info" className="mt-4 space-y-6">
1235
+ <Form {...editForm}>
1236
+ <form
1237
+ onSubmit={editForm.handleSubmit(onEditSubmit)}
1238
+ className="space-y-4"
1239
+ >
1240
+ <FormField
1241
+ control={editForm.control}
1242
+ name="name"
1243
+ render={({ field }) => (
1244
+ <FormItem>
1245
+ <FormLabel>{t('editNameLabel')}</FormLabel>
1246
+ <FormControl>
1247
+ <Input
1248
+ placeholder={t('editNamePlaceholder')}
1249
+ {...field}
1250
+ />
1251
+ </FormControl>
1252
+ <FormMessage />
1253
+ </FormItem>
1254
+ )}
1255
+ />
1256
+ <FormField
1257
+ control={editForm.control}
1258
+ name="slug"
1259
+ render={({ field }) => (
1260
+ <FormItem>
1261
+ <FormLabel>{t('editSlugLabel')}</FormLabel>
1262
+ <FormControl>
1263
+ <Input {...field} />
1264
+ </FormControl>
1265
+ <FormMessage />
1266
+ </FormItem>
1267
+ )}
1268
+ />
1269
+ <FormField
1270
+ control={editForm.control}
1271
+ name="url"
1272
+ render={({ field }) => (
1273
+ <FormItem>
1274
+ <FormLabel>{t('editUrlLabel')}</FormLabel>
1275
+ <FormControl>
1276
+ <Input {...field} />
1277
+ </FormControl>
1278
+ <FormMessage />
1279
+ </FormItem>
1280
+ )}
1281
+ />
1282
+ <div className="grid grid-cols-2 gap-4">
1283
+ <FormField
1284
+ control={editForm.control}
1285
+ name="icon"
1286
+ render={({ field }) => (
1287
+ <FormItem>
1288
+ <FormLabel>{t('editIconLabel')}</FormLabel>
1289
+ <FormControl>
1290
+ <Input {...field} value={field.value ?? ''} />
1291
+ </FormControl>
1292
+ <FormMessage />
1293
+ </FormItem>
1294
+ )}
1295
+ />
1296
+ <FormField
1297
+ control={editForm.control}
1298
+ name="order"
1299
+ render={({ field }) => (
1300
+ <FormItem>
1301
+ <FormLabel>{t('editOrderLabel')}</FormLabel>
1302
+ <FormControl>
1303
+ <Input
1304
+ type="number"
1305
+ min={1}
1306
+ {...field}
1307
+ value={field.value ?? ''}
1308
+ />
1309
+ </FormControl>
1310
+ <FormMessage />
1311
+ </FormItem>
1312
+ )}
1313
+ />
1314
+ </div>
1315
+
1316
+ <FormField
1317
+ control={editForm.control}
1318
+ name="menu_id"
1319
+ render={({ field }) => (
1320
+ <FormItem>
1321
+ <FormLabel>{t('editParentMenuLabel')}</FormLabel>
1322
+ <Select
1323
+ value={field.value ?? NO_PARENT}
1324
+ onValueChange={field.onChange}
1325
+ >
1326
+ <FormControl>
1327
+ <SelectTrigger className="w-full">
1328
+ <SelectValue
1329
+ placeholder={t(
1330
+ 'editParentMenuPlaceholder'
1331
+ )}
1332
+ />
1333
+ </SelectTrigger>
1334
+ </FormControl>
1335
+ <SelectContent className="w-full">
1336
+ <SelectItem value={NO_PARENT}>
1337
+ {t('formParentMenuNone')}
1338
+ </SelectItem>
1339
+ {parentMenuOptions.map((m) => (
1340
+ <SelectItem key={m.id} value={String(m.id)}>
1341
+ {getMenuDisplayName(m)}
1342
+ </SelectItem>
1343
+ ))}
1344
+ </SelectContent>
1345
+ </Select>
1346
+ <FormMessage />
1347
+ </FormItem>
1348
+ )}
1349
+ />
1350
+
1351
+ {editFormError && (
1352
+ <Alert
1353
+ variant="destructive"
1354
+ className="border-red-300 bg-red-50 rounded-md p-4"
1355
+ >
1356
+ <AlertTitle className="text-sm">
1357
+ {t('verifyYourInput')}
1358
+ </AlertTitle>
1359
+ <AlertDescription className="text-sm">
1360
+ {editFormError}
1361
+ </AlertDescription>
1362
+ </Alert>
1363
+ )}
1364
+
1365
+ <div className="flex flex-col w-full gap-2 pt-2">
1366
+ <Button type="submit" className="w-full">
1367
+ {t('saveChanges')}
1368
+ </Button>
1369
+ <Button
1370
+ className="w-full"
1371
+ type="button"
1372
+ variant="outline"
1373
+ onClick={() => setEditingMenu(null)}
1374
+ >
1375
+ {t('cancel')}
1376
+ </Button>
1377
+ </div>
1378
+ </form>
1379
+ </Form>
1380
+
1381
+ <div className="border-t pt-4">
1382
+ <Button
1383
+ className="w-full cursor-pointer"
1384
+ variant="destructive"
1385
+ onClick={() => setOpenDeleteModal(true)}
1386
+ >
1387
+ <Trash2 className="w-4 h-4" />
1388
+ <span>{t('buttonDeleteMenu')}</span>
1389
+ </Button>
1390
+ </div>
1391
+ </TabsContent>
1392
+
1393
+ <TabsContent value="roles" className="mt-4">
1394
+ <MenuRolesSection menuId={editingMenu.id} />
1395
+ </TabsContent>
1396
+ </Tabs>
1397
+ </div>
1398
+ </SheetContent>
1399
+ </Sheet>
1400
+ )}
1401
+
1402
+ <Dialog open={openDeleteModal} onOpenChange={setOpenDeleteModal}>
1403
+ <DialogContent className="sm:max-w-lg">
1404
+ <DialogHeader>
1405
+ <DialogTitle>{t('dialogDeleteMenuTitle')}</DialogTitle>
1406
+ <DialogDescription>
1407
+ {t('dialogDeleteMenuDescription')}
1408
+ </DialogDescription>
1409
+ </DialogHeader>
1410
+ <hr className="mt-4" />
1411
+ <div className="flex justify-end">
1412
+ <Button
1413
+ type="button"
1414
+ className="px-4 w-28 h-12 py-2 bg-gray-300 text-black hover:bg-gray-300 hover:text-black rounded-sm mr-2 text-md"
1415
+ onClick={() => setOpenDeleteModal(false)}
1416
+ >
1417
+ {t('deleteMenuCancel')}
1418
+ </Button>
1419
+ <Button
1420
+ onClick={onDelete}
1421
+ variant="destructive"
1422
+ className="px-4 w-32 h-12 py-2 text-white hover:text-white rounded-sm text-md cursor-pointer"
1423
+ >
1424
+ {t('deleteMenuConfirm')}
1425
+ </Button>
1426
+ </div>
1427
+ </DialogContent>
1428
+ </Dialog>
1429
+
1430
+ <MenuTreeDialog
1431
+ open={isTreeOpen}
1432
+ onClose={() => setIsTreeOpen(false)}
1433
+ menus={(allMenus ?? []) as TreeNode[]}
1434
+ onSaved={() => {
1435
+ refetch();
1436
+ }}
1437
+ />
1438
+ </div>
1439
+ </div>
1440
+ );
1441
+ }