@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
|
+
}
|