@hed-hog/category 0.0.186 → 0.0.187
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,827 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
PageHeader,
|
|
5
|
+
PaginationFooter,
|
|
6
|
+
SearchBar,
|
|
7
|
+
} from '@/components/entity-list';
|
|
8
|
+
import {
|
|
9
|
+
AlertDialog,
|
|
10
|
+
AlertDialogAction,
|
|
11
|
+
AlertDialogCancel,
|
|
12
|
+
AlertDialogContent,
|
|
13
|
+
AlertDialogDescription,
|
|
14
|
+
AlertDialogFooter,
|
|
15
|
+
AlertDialogHeader,
|
|
16
|
+
AlertDialogTitle,
|
|
17
|
+
AlertDialogTrigger,
|
|
18
|
+
} from '@/components/ui/alert-dialog';
|
|
19
|
+
import { Badge } from '@/components/ui/badge';
|
|
20
|
+
import { Button } from '@/components/ui/button';
|
|
21
|
+
import { Card, CardContent } from '@/components/ui/card';
|
|
22
|
+
import {
|
|
23
|
+
Dialog,
|
|
24
|
+
DialogContent,
|
|
25
|
+
DialogDescription,
|
|
26
|
+
DialogFooter,
|
|
27
|
+
DialogHeader,
|
|
28
|
+
DialogTitle,
|
|
29
|
+
} from '@/components/ui/dialog';
|
|
30
|
+
import { Input } from '@/components/ui/input';
|
|
31
|
+
import { Label } from '@/components/ui/label';
|
|
32
|
+
import {
|
|
33
|
+
Select,
|
|
34
|
+
SelectContent,
|
|
35
|
+
SelectItem,
|
|
36
|
+
SelectTrigger,
|
|
37
|
+
SelectValue,
|
|
38
|
+
} from '@/components/ui/select';
|
|
39
|
+
import { useDebounce } from '@/hooks/use-debounce';
|
|
40
|
+
import { useApp, useQuery } from '@hed-hog/next-app-provider';
|
|
41
|
+
import * as TablerIcons from '@tabler/icons-react';
|
|
42
|
+
import {
|
|
43
|
+
Edit,
|
|
44
|
+
FolderTree,
|
|
45
|
+
Globe,
|
|
46
|
+
Layers,
|
|
47
|
+
Plus,
|
|
48
|
+
Save,
|
|
49
|
+
Tag,
|
|
50
|
+
Trash2,
|
|
51
|
+
X,
|
|
52
|
+
} from 'lucide-react';
|
|
53
|
+
import { useTranslations } from 'next-intl';
|
|
54
|
+
import { useEffect, useState } from 'react';
|
|
55
|
+
import { toast } from 'sonner';
|
|
56
|
+
|
|
57
|
+
type PaginationResult<T> = {
|
|
58
|
+
data: T[];
|
|
59
|
+
total: number;
|
|
60
|
+
page: number;
|
|
61
|
+
pageSize: number;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
type CategoryLocale = {
|
|
65
|
+
name: string;
|
|
66
|
+
locale_id?: number;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
type Category = {
|
|
70
|
+
id: number;
|
|
71
|
+
slug: string;
|
|
72
|
+
name: string;
|
|
73
|
+
category_id?: number | null;
|
|
74
|
+
color?: string;
|
|
75
|
+
icon?: string;
|
|
76
|
+
status: 'active' | 'inactive';
|
|
77
|
+
category_locale?: CategoryLocale[];
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
type Locale = {
|
|
81
|
+
id?: number;
|
|
82
|
+
code: string;
|
|
83
|
+
name: string;
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
export default function CategoryPage() {
|
|
87
|
+
const t = useTranslations('category.Category');
|
|
88
|
+
const [categories, setCategories] = useState<Category[]>([]);
|
|
89
|
+
const [selectedCategory, setSelectedCategory] = useState<any | null>(null);
|
|
90
|
+
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
|
|
91
|
+
const [isNewCategory, setIsNewCategory] = useState(false);
|
|
92
|
+
const [searchTerm, setSearchTerm] = useState('');
|
|
93
|
+
const debouncedSearch = useDebounce(searchTerm);
|
|
94
|
+
const [page, setPage] = useState(1);
|
|
95
|
+
const [pageSize, setPageSize] = useState(10);
|
|
96
|
+
const [statusFilter, setStatusFilter] = useState<string>('all');
|
|
97
|
+
const [parentFilter, setParentFilter] = useState<string>('all');
|
|
98
|
+
const [selectedLocale, setSelectedLocale] = useState<string>('');
|
|
99
|
+
const [fullLocales, setFullLocales] = useState<any[]>([]);
|
|
100
|
+
const { request, locales, currentLocaleCode } = useApp();
|
|
101
|
+
|
|
102
|
+
// Buscar locales completos com id
|
|
103
|
+
useEffect(() => {
|
|
104
|
+
const fetchLocales = async () => {
|
|
105
|
+
try {
|
|
106
|
+
const response: any = await request({
|
|
107
|
+
url: '/locale',
|
|
108
|
+
params: { pageSize: 100 },
|
|
109
|
+
});
|
|
110
|
+
if (response.data?.data) {
|
|
111
|
+
setFullLocales(response.data.data);
|
|
112
|
+
}
|
|
113
|
+
} catch (error) {
|
|
114
|
+
console.error('Erro ao buscar locales:', error);
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
fetchLocales();
|
|
118
|
+
}, []);
|
|
119
|
+
|
|
120
|
+
useEffect(() => {
|
|
121
|
+
if (currentLocaleCode && !selectedLocale) {
|
|
122
|
+
setSelectedLocale(currentLocaleCode);
|
|
123
|
+
}
|
|
124
|
+
}, [currentLocaleCode]);
|
|
125
|
+
|
|
126
|
+
const {
|
|
127
|
+
data: { data, total },
|
|
128
|
+
refetch: refetchCategories,
|
|
129
|
+
} = useQuery<PaginationResult<Category>>({
|
|
130
|
+
queryKey: [
|
|
131
|
+
'categories',
|
|
132
|
+
debouncedSearch,
|
|
133
|
+
page,
|
|
134
|
+
pageSize,
|
|
135
|
+
statusFilter,
|
|
136
|
+
parentFilter,
|
|
137
|
+
],
|
|
138
|
+
queryFn: async () => {
|
|
139
|
+
const response = await request({
|
|
140
|
+
url: '/category',
|
|
141
|
+
params: {
|
|
142
|
+
search: debouncedSearch,
|
|
143
|
+
page,
|
|
144
|
+
pageSize,
|
|
145
|
+
status: statusFilter,
|
|
146
|
+
parent: parentFilter,
|
|
147
|
+
},
|
|
148
|
+
});
|
|
149
|
+
return response.data as PaginationResult<Category>;
|
|
150
|
+
},
|
|
151
|
+
initialData: {
|
|
152
|
+
data: [],
|
|
153
|
+
total: 0,
|
|
154
|
+
page: 1,
|
|
155
|
+
pageSize: 10,
|
|
156
|
+
},
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
const { data: rootCategories = [] } = useQuery<Category[]>({
|
|
160
|
+
queryKey: ['root-categories'],
|
|
161
|
+
queryFn: async () => {
|
|
162
|
+
const response = await request({
|
|
163
|
+
url: '/category/root',
|
|
164
|
+
});
|
|
165
|
+
return (response.data || []) as Category[];
|
|
166
|
+
},
|
|
167
|
+
initialData: [],
|
|
168
|
+
enabled: true,
|
|
169
|
+
staleTime: 0,
|
|
170
|
+
refetchOnMount: true,
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
const { data: statsData, refetch: refetchStats } = useQuery<any>({
|
|
174
|
+
queryKey: ['category-stats'],
|
|
175
|
+
queryFn: async () => {
|
|
176
|
+
const response = await request({
|
|
177
|
+
url: '/category/stats',
|
|
178
|
+
});
|
|
179
|
+
return response.data;
|
|
180
|
+
},
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
useEffect(() => {
|
|
184
|
+
if (data) {
|
|
185
|
+
setCategories(data);
|
|
186
|
+
}
|
|
187
|
+
}, [data]);
|
|
188
|
+
|
|
189
|
+
const renderIcon = (iconName?: string) => {
|
|
190
|
+
const toPascalCase = (str: string) =>
|
|
191
|
+
str.replace(/(^\w|-\w)/g, (match) =>
|
|
192
|
+
match.replace('-', '').toUpperCase()
|
|
193
|
+
);
|
|
194
|
+
|
|
195
|
+
const pascalName = toPascalCase(String(iconName));
|
|
196
|
+
|
|
197
|
+
let IconComponent = (TablerIcons as any)[`Icon${pascalName}`];
|
|
198
|
+
if (!IconComponent) {
|
|
199
|
+
IconComponent = (TablerIcons as any)[`Icon${pascalName}Filled`];
|
|
200
|
+
}
|
|
201
|
+
if (!IconComponent) {
|
|
202
|
+
IconComponent = (TablerIcons as any)[`Icon${pascalName}Circle`];
|
|
203
|
+
}
|
|
204
|
+
if (IconComponent) {
|
|
205
|
+
return <IconComponent className="h-4 w-4" />;
|
|
206
|
+
}
|
|
207
|
+
// fallback para Briefcase se não encontrar
|
|
208
|
+
return <TablerIcons.IconBriefcase className="h-4 w-4" />;
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
const handleNewCategory = (): void => {
|
|
212
|
+
const newCategory: any = {
|
|
213
|
+
slug: '',
|
|
214
|
+
category_id: null,
|
|
215
|
+
color: '#000000',
|
|
216
|
+
icon: '',
|
|
217
|
+
status: 'active',
|
|
218
|
+
locale: {},
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
locales.forEach((locale: Locale) => {
|
|
222
|
+
newCategory.locale[locale.code] = {
|
|
223
|
+
name: '',
|
|
224
|
+
};
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
setSelectedCategory(newCategory);
|
|
228
|
+
setIsNewCategory(true);
|
|
229
|
+
setIsEditDialogOpen(true);
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
const handleEditCategory = async (category: Category): Promise<void> => {
|
|
233
|
+
try {
|
|
234
|
+
const response = await request({
|
|
235
|
+
url: `/category/${category.category_id}`,
|
|
236
|
+
method: 'GET',
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
const categoryData = response.data as any;
|
|
240
|
+
const localeData: Record<string, { name: string }> = {};
|
|
241
|
+
|
|
242
|
+
if (
|
|
243
|
+
categoryData.category_locale &&
|
|
244
|
+
Array.isArray(categoryData.category_locale)
|
|
245
|
+
) {
|
|
246
|
+
categoryData.category_locale.forEach((cl: any) => {
|
|
247
|
+
const locale = locales.find((l: Locale) => {
|
|
248
|
+
const fullLocale = fullLocales.find(
|
|
249
|
+
(fl: any) => fl.id === cl.locale_id
|
|
250
|
+
);
|
|
251
|
+
return fullLocale?.code === l.code;
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
if (locale) {
|
|
255
|
+
localeData[locale.code] = {
|
|
256
|
+
name: cl.name || '',
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Garantir que todos os locales estejam presentes
|
|
263
|
+
locales.forEach((locale: Locale) => {
|
|
264
|
+
if (!localeData[locale.code]) {
|
|
265
|
+
localeData[locale.code] = {
|
|
266
|
+
name: '',
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
setSelectedCategory({
|
|
272
|
+
...categoryData,
|
|
273
|
+
locale: localeData,
|
|
274
|
+
});
|
|
275
|
+
setIsNewCategory(false);
|
|
276
|
+
setIsEditDialogOpen(true);
|
|
277
|
+
} catch (error) {
|
|
278
|
+
console.error(error);
|
|
279
|
+
toast.error(t('errorLoading'));
|
|
280
|
+
}
|
|
281
|
+
};
|
|
282
|
+
|
|
283
|
+
const handleSaveCategory = async () => {
|
|
284
|
+
if (!selectedCategory) return;
|
|
285
|
+
|
|
286
|
+
const payload = {
|
|
287
|
+
locale: selectedCategory.locale,
|
|
288
|
+
slug: selectedCategory.slug,
|
|
289
|
+
category_id: selectedCategory.category_id || null,
|
|
290
|
+
color: selectedCategory.color,
|
|
291
|
+
icon: selectedCategory.icon,
|
|
292
|
+
status: selectedCategory.status,
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
try {
|
|
296
|
+
if (selectedCategory.id) {
|
|
297
|
+
await request({
|
|
298
|
+
url: `/category/${selectedCategory.id}`,
|
|
299
|
+
method: 'PATCH',
|
|
300
|
+
data: payload,
|
|
301
|
+
});
|
|
302
|
+
toast.success(t('successUpdate'));
|
|
303
|
+
} else {
|
|
304
|
+
await request({
|
|
305
|
+
url: `/category`,
|
|
306
|
+
method: 'POST',
|
|
307
|
+
data: payload,
|
|
308
|
+
});
|
|
309
|
+
toast.success(t('successCreate'));
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
setIsEditDialogOpen(false);
|
|
313
|
+
await refetchCategories();
|
|
314
|
+
await refetchStats();
|
|
315
|
+
} catch (error) {
|
|
316
|
+
console.error(error);
|
|
317
|
+
toast.error(t('errorSave'));
|
|
318
|
+
}
|
|
319
|
+
};
|
|
320
|
+
|
|
321
|
+
const handleDeleteCategory = async (categoryId: number): Promise<void> => {
|
|
322
|
+
try {
|
|
323
|
+
await request({
|
|
324
|
+
url: `/category/${categoryId}`,
|
|
325
|
+
method: 'DELETE',
|
|
326
|
+
});
|
|
327
|
+
toast.success(t('successDelete'));
|
|
328
|
+
await refetchCategories();
|
|
329
|
+
await refetchStats();
|
|
330
|
+
} catch (error) {
|
|
331
|
+
console.error(error);
|
|
332
|
+
toast.error(t('errorDelete'));
|
|
333
|
+
}
|
|
334
|
+
};
|
|
335
|
+
|
|
336
|
+
const handleSearchChange = (value: string): void => {
|
|
337
|
+
setSearchTerm(value);
|
|
338
|
+
};
|
|
339
|
+
|
|
340
|
+
useEffect(() => {
|
|
341
|
+
refetchCategories();
|
|
342
|
+
refetchStats();
|
|
343
|
+
}, [
|
|
344
|
+
isEditDialogOpen,
|
|
345
|
+
debouncedSearch,
|
|
346
|
+
page,
|
|
347
|
+
pageSize,
|
|
348
|
+
statusFilter,
|
|
349
|
+
parentFilter,
|
|
350
|
+
]);
|
|
351
|
+
|
|
352
|
+
const getStatusBadge = (status: string) => {
|
|
353
|
+
return status === 'active' ? (
|
|
354
|
+
<Badge variant="default" className="bg-green-500">
|
|
355
|
+
{t('active')}
|
|
356
|
+
</Badge>
|
|
357
|
+
) : (
|
|
358
|
+
<Badge variant="secondary">{t('inactive')}</Badge>
|
|
359
|
+
);
|
|
360
|
+
};
|
|
361
|
+
|
|
362
|
+
return (
|
|
363
|
+
<div className="flex flex-col h-screen px-4">
|
|
364
|
+
<PageHeader
|
|
365
|
+
breadcrumbs={[
|
|
366
|
+
{ label: 'Home', href: '/' },
|
|
367
|
+
{ label: t('description') },
|
|
368
|
+
]}
|
|
369
|
+
actions={[
|
|
370
|
+
{
|
|
371
|
+
label: t('newCategory'),
|
|
372
|
+
onClick: () => handleNewCategory(),
|
|
373
|
+
variant: 'default',
|
|
374
|
+
},
|
|
375
|
+
]}
|
|
376
|
+
title={t('title')}
|
|
377
|
+
description={t('description')}
|
|
378
|
+
/>
|
|
379
|
+
|
|
380
|
+
<div className="grid grid-cols-1 gap-4 md:grid-cols-4">
|
|
381
|
+
<Card className="transition-shadow hover:shadow-md p-0">
|
|
382
|
+
<CardContent className="p-4">
|
|
383
|
+
<div className="flex items-center space-x-3">
|
|
384
|
+
<div className="rounded-full bg-blue-100 p-2 dark:bg-blue-900">
|
|
385
|
+
<Layers className="h-6 w-6 text-blue-600 dark:text-blue-400" />
|
|
386
|
+
</div>
|
|
387
|
+
<div>
|
|
388
|
+
<p className="text-sm font-medium text-muted-foreground">
|
|
389
|
+
{t('totalCategories')}
|
|
390
|
+
</p>
|
|
391
|
+
<p className="text-2xl font-bold">{statsData?.total || 0}</p>
|
|
392
|
+
</div>
|
|
393
|
+
</div>
|
|
394
|
+
</CardContent>
|
|
395
|
+
</Card>
|
|
396
|
+
|
|
397
|
+
<Card className="transition-shadow hover:shadow-md p-0">
|
|
398
|
+
<CardContent className="p-4">
|
|
399
|
+
<div className="flex items-center space-x-3">
|
|
400
|
+
<div className="rounded-full bg-green-100 p-2 dark:bg-green-900">
|
|
401
|
+
<Tag className="h-6 w-6 text-green-600 dark:text-green-400" />
|
|
402
|
+
</div>
|
|
403
|
+
<div>
|
|
404
|
+
<p className="text-sm font-medium text-muted-foreground">
|
|
405
|
+
{t('actives')}
|
|
406
|
+
</p>
|
|
407
|
+
<p className="text-2xl font-bold">
|
|
408
|
+
{statsData?.totalActive || 0}
|
|
409
|
+
</p>
|
|
410
|
+
</div>
|
|
411
|
+
</div>
|
|
412
|
+
</CardContent>
|
|
413
|
+
</Card>
|
|
414
|
+
|
|
415
|
+
<Card className="transition-shadow hover:shadow-md p-0">
|
|
416
|
+
<CardContent className="p-4">
|
|
417
|
+
<div className="flex items-center space-x-3">
|
|
418
|
+
<div className="rounded-full bg-orange-100 p-2 dark:bg-orange-900">
|
|
419
|
+
<Tag className="h-6 w-6 text-orange-600 dark:text-orange-400" />
|
|
420
|
+
</div>
|
|
421
|
+
<div>
|
|
422
|
+
<p className="text-sm font-medium text-muted-foreground">
|
|
423
|
+
{t('inactives')}
|
|
424
|
+
</p>
|
|
425
|
+
<p className="text-2xl font-bold">
|
|
426
|
+
{statsData?.totalInactive || 0}
|
|
427
|
+
</p>
|
|
428
|
+
</div>
|
|
429
|
+
</div>
|
|
430
|
+
</CardContent>
|
|
431
|
+
</Card>
|
|
432
|
+
|
|
433
|
+
<Card className="transition-shadow hover:shadow-md p-0">
|
|
434
|
+
<CardContent className="p-4">
|
|
435
|
+
<div className="flex items-center space-x-3">
|
|
436
|
+
<div className="rounded-full bg-purple-100 p-2 dark:bg-purple-900">
|
|
437
|
+
<FolderTree className="h-6 w-6 text-purple-600 dark:text-purple-400" />
|
|
438
|
+
</div>
|
|
439
|
+
<div>
|
|
440
|
+
<p className="text-sm font-medium text-muted-foreground">
|
|
441
|
+
{t('root')}
|
|
442
|
+
</p>
|
|
443
|
+
<p className="text-2xl font-bold">
|
|
444
|
+
{statsData?.totalRoot || 0}
|
|
445
|
+
</p>
|
|
446
|
+
</div>
|
|
447
|
+
</div>
|
|
448
|
+
</CardContent>
|
|
449
|
+
</Card>
|
|
450
|
+
</div>
|
|
451
|
+
|
|
452
|
+
<div className="flex flex-col gap-4 sm:flex-row my-4">
|
|
453
|
+
<SearchBar
|
|
454
|
+
searchQuery={searchTerm}
|
|
455
|
+
onSearchChange={handleSearchChange}
|
|
456
|
+
onSearch={() => refetchCategories()}
|
|
457
|
+
placeholder={t('searchPlaceholder')}
|
|
458
|
+
/>
|
|
459
|
+
|
|
460
|
+
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
|
461
|
+
<SelectTrigger className="w-full sm:w-[180px]">
|
|
462
|
+
<SelectValue placeholder={t('status')} />
|
|
463
|
+
</SelectTrigger>
|
|
464
|
+
<SelectContent>
|
|
465
|
+
<SelectItem value="all">{t('all')}</SelectItem>
|
|
466
|
+
<SelectItem value="active">{t('actives')}</SelectItem>
|
|
467
|
+
<SelectItem value="inactive">{t('inactives')}</SelectItem>
|
|
468
|
+
</SelectContent>
|
|
469
|
+
</Select>
|
|
470
|
+
<Select value={parentFilter} onValueChange={setParentFilter}>
|
|
471
|
+
<SelectTrigger className="w-full sm:w-[180px]">
|
|
472
|
+
<SelectValue placeholder={t('hierarchy')} />
|
|
473
|
+
</SelectTrigger>
|
|
474
|
+
<SelectContent>
|
|
475
|
+
<SelectItem value="all">{t('allHierarchy')}</SelectItem>
|
|
476
|
+
<SelectItem value="root">{t('rootHierarchy')}</SelectItem>
|
|
477
|
+
</SelectContent>
|
|
478
|
+
</Select>
|
|
479
|
+
</div>
|
|
480
|
+
|
|
481
|
+
<div className="space-y-4">
|
|
482
|
+
{categories.length > 0 ? (
|
|
483
|
+
<div className="space-y-4">
|
|
484
|
+
{categories.map((category) => (
|
|
485
|
+
<Card
|
|
486
|
+
key={category.id}
|
|
487
|
+
onDoubleClick={() => handleEditCategory(category)}
|
|
488
|
+
className="cursor-pointer transition-all duration-200 hover:border-primary/20 hover:shadow-md"
|
|
489
|
+
>
|
|
490
|
+
<CardContent className="p-6">
|
|
491
|
+
<div className="flex items-start justify-between gap-4">
|
|
492
|
+
<div className="flex-1 space-y-3">
|
|
493
|
+
<div className="flex items-start space-x-3">
|
|
494
|
+
<div
|
|
495
|
+
className="mt-1 rounded-full p-2"
|
|
496
|
+
style={{
|
|
497
|
+
backgroundColor: category.color
|
|
498
|
+
? `${category.color}20`
|
|
499
|
+
: '#00000020',
|
|
500
|
+
}}
|
|
501
|
+
>
|
|
502
|
+
<div style={{ color: category.color || '#000000' }}>
|
|
503
|
+
{renderIcon(category.icon)}
|
|
504
|
+
</div>
|
|
505
|
+
</div>
|
|
506
|
+
<div className="flex-1 space-y-2">
|
|
507
|
+
<div className="flex items-center gap-2 flex-wrap">
|
|
508
|
+
<h3 className="text-lg font-semibold leading-tight">
|
|
509
|
+
{category.name}
|
|
510
|
+
</h3>
|
|
511
|
+
{getStatusBadge(category.status)}
|
|
512
|
+
</div>
|
|
513
|
+
<div className="flex flex-col gap-1 text-sm text-muted-foreground">
|
|
514
|
+
<span>
|
|
515
|
+
<strong>{t('slug')}:</strong> {category.slug}
|
|
516
|
+
</span>
|
|
517
|
+
{category.color && (
|
|
518
|
+
<span className="flex items-center gap-2">
|
|
519
|
+
<strong>{t('color')}:</strong>
|
|
520
|
+
<span
|
|
521
|
+
className="inline-block h-4 w-4 rounded border"
|
|
522
|
+
style={{
|
|
523
|
+
backgroundColor: category.color,
|
|
524
|
+
}}
|
|
525
|
+
/>
|
|
526
|
+
{category.color}
|
|
527
|
+
</span>
|
|
528
|
+
)}
|
|
529
|
+
{category.icon && (
|
|
530
|
+
<span>
|
|
531
|
+
<strong>{t('icon')}:</strong> {category.icon}
|
|
532
|
+
</span>
|
|
533
|
+
)}
|
|
534
|
+
</div>
|
|
535
|
+
</div>
|
|
536
|
+
</div>
|
|
537
|
+
</div>
|
|
538
|
+
|
|
539
|
+
<div className="flex flex-col gap-2">
|
|
540
|
+
<Button
|
|
541
|
+
variant="outline"
|
|
542
|
+
size="sm"
|
|
543
|
+
onClick={() => handleEditCategory(category)}
|
|
544
|
+
className="transition-colors hover:border-blue-200 hover:bg-blue-50 hover:text-blue-600 dark:hover:bg-blue-950"
|
|
545
|
+
>
|
|
546
|
+
<Edit className="mr-1 h-4 w-4" />
|
|
547
|
+
{t('edit')}
|
|
548
|
+
</Button>
|
|
549
|
+
|
|
550
|
+
<AlertDialog>
|
|
551
|
+
<AlertDialogTrigger asChild>
|
|
552
|
+
<Button
|
|
553
|
+
variant="outline"
|
|
554
|
+
size="sm"
|
|
555
|
+
className="bg-transparent transition-colors hover:border-red-200 hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-950"
|
|
556
|
+
>
|
|
557
|
+
<Trash2 className="mr-1 h-4 w-4" />
|
|
558
|
+
{t('delete')}
|
|
559
|
+
</Button>
|
|
560
|
+
</AlertDialogTrigger>
|
|
561
|
+
<AlertDialogContent>
|
|
562
|
+
<AlertDialogHeader>
|
|
563
|
+
<AlertDialogTitle>
|
|
564
|
+
{t('confirmDelete')}
|
|
565
|
+
</AlertDialogTitle>
|
|
566
|
+
<AlertDialogDescription>
|
|
567
|
+
{t('deleteDescription')}
|
|
568
|
+
</AlertDialogDescription>
|
|
569
|
+
</AlertDialogHeader>
|
|
570
|
+
<AlertDialogFooter>
|
|
571
|
+
<AlertDialogCancel>{t('cancel')}</AlertDialogCancel>
|
|
572
|
+
<AlertDialogAction
|
|
573
|
+
onClick={() =>
|
|
574
|
+
handleDeleteCategory(
|
|
575
|
+
Number(category.category_id)
|
|
576
|
+
)
|
|
577
|
+
}
|
|
578
|
+
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
|
579
|
+
>
|
|
580
|
+
{t('delete')}
|
|
581
|
+
</AlertDialogAction>
|
|
582
|
+
</AlertDialogFooter>
|
|
583
|
+
</AlertDialogContent>
|
|
584
|
+
</AlertDialog>
|
|
585
|
+
</div>
|
|
586
|
+
</div>
|
|
587
|
+
</CardContent>
|
|
588
|
+
</Card>
|
|
589
|
+
))}
|
|
590
|
+
</div>
|
|
591
|
+
) : (
|
|
592
|
+
<Card>
|
|
593
|
+
<CardContent className="p-12 text-center">
|
|
594
|
+
<div className="flex flex-col items-center space-y-4">
|
|
595
|
+
<Layers className="h-12 w-12 text-muted-foreground" />
|
|
596
|
+
<div>
|
|
597
|
+
<h3 className="text-lg font-semibold">
|
|
598
|
+
{t('noCategoriesFound')}
|
|
599
|
+
</h3>
|
|
600
|
+
<p className="text-muted-foreground">{t('adjustFilters')}</p>
|
|
601
|
+
</div>
|
|
602
|
+
<Button onClick={handleNewCategory}>
|
|
603
|
+
<Plus className="mr-2 h-4 w-4" />
|
|
604
|
+
{t('createFirstCategory')}
|
|
605
|
+
</Button>
|
|
606
|
+
</div>
|
|
607
|
+
</CardContent>
|
|
608
|
+
</Card>
|
|
609
|
+
)}
|
|
610
|
+
|
|
611
|
+
<PaginationFooter
|
|
612
|
+
currentPage={page}
|
|
613
|
+
pageSize={pageSize}
|
|
614
|
+
totalItems={total}
|
|
615
|
+
onPageChange={setPage}
|
|
616
|
+
onPageSizeChange={setPageSize}
|
|
617
|
+
pageSizeOptions={[10, 20, 30, 40, 50]}
|
|
618
|
+
/>
|
|
619
|
+
</div>
|
|
620
|
+
|
|
621
|
+
<Dialog open={isEditDialogOpen} onOpenChange={setIsEditDialogOpen}>
|
|
622
|
+
<DialogContent className="max-h-[95vh] max-w-2xl overflow-y-auto">
|
|
623
|
+
<DialogHeader>
|
|
624
|
+
<DialogTitle className="flex items-center space-x-2">
|
|
625
|
+
<Edit className="h-5 w-5" />
|
|
626
|
+
<span>
|
|
627
|
+
{isNewCategory ? t('newCategoryTitle') : t('editCategory')}
|
|
628
|
+
</span>
|
|
629
|
+
</DialogTitle>
|
|
630
|
+
<DialogDescription>
|
|
631
|
+
{isNewCategory ? t('createDescription') : t('editDescription')}
|
|
632
|
+
</DialogDescription>
|
|
633
|
+
</DialogHeader>
|
|
634
|
+
|
|
635
|
+
{selectedCategory && (
|
|
636
|
+
<div className="space-y-4">
|
|
637
|
+
<div className="space-y-2">
|
|
638
|
+
<Label
|
|
639
|
+
htmlFor="locale-select"
|
|
640
|
+
className="flex items-center gap-2"
|
|
641
|
+
>
|
|
642
|
+
<Globe className="h-4 w-4" />
|
|
643
|
+
{t('language')}
|
|
644
|
+
</Label>
|
|
645
|
+
<Select
|
|
646
|
+
value={selectedLocale}
|
|
647
|
+
onValueChange={setSelectedLocale}
|
|
648
|
+
>
|
|
649
|
+
<SelectTrigger id="locale-select">
|
|
650
|
+
<SelectValue placeholder={t('selectLanguage')} />
|
|
651
|
+
</SelectTrigger>
|
|
652
|
+
<SelectContent>
|
|
653
|
+
{locales.map((locale: Locale) => (
|
|
654
|
+
<SelectItem key={locale.code} value={locale.code}>
|
|
655
|
+
{locale.name}
|
|
656
|
+
</SelectItem>
|
|
657
|
+
))}
|
|
658
|
+
</SelectContent>
|
|
659
|
+
</Select>
|
|
660
|
+
</div>
|
|
661
|
+
|
|
662
|
+
<div className="space-y-2">
|
|
663
|
+
<Label htmlFor="name">{t('nameRequired')}</Label>
|
|
664
|
+
<Input
|
|
665
|
+
id="name"
|
|
666
|
+
placeholder={t('namePlaceholder')}
|
|
667
|
+
value={selectedCategory.locale?.[selectedLocale]?.name || ''}
|
|
668
|
+
onChange={(e) =>
|
|
669
|
+
setSelectedCategory({
|
|
670
|
+
...selectedCategory,
|
|
671
|
+
locale: {
|
|
672
|
+
...selectedCategory.locale,
|
|
673
|
+
[selectedLocale]: {
|
|
674
|
+
...selectedCategory.locale?.[selectedLocale],
|
|
675
|
+
name: e.target.value,
|
|
676
|
+
},
|
|
677
|
+
},
|
|
678
|
+
})
|
|
679
|
+
}
|
|
680
|
+
/>
|
|
681
|
+
</div>
|
|
682
|
+
|
|
683
|
+
<div className="space-y-2">
|
|
684
|
+
<Label htmlFor="slug">{t('slugRequired')}</Label>
|
|
685
|
+
<Input
|
|
686
|
+
id="slug"
|
|
687
|
+
placeholder={t('slugPlaceholder')}
|
|
688
|
+
value={selectedCategory.slug || ''}
|
|
689
|
+
onChange={(e) =>
|
|
690
|
+
setSelectedCategory({
|
|
691
|
+
...selectedCategory,
|
|
692
|
+
slug: e.target.value,
|
|
693
|
+
})
|
|
694
|
+
}
|
|
695
|
+
/>
|
|
696
|
+
</div>
|
|
697
|
+
|
|
698
|
+
<div className="space-y-2">
|
|
699
|
+
<Label htmlFor="color">{t('color')}</Label>
|
|
700
|
+
<div className="flex gap-2">
|
|
701
|
+
<Input
|
|
702
|
+
id="color"
|
|
703
|
+
type="color"
|
|
704
|
+
className="h-10 w-20"
|
|
705
|
+
value={selectedCategory.color || '#000000'}
|
|
706
|
+
onChange={(e) =>
|
|
707
|
+
setSelectedCategory({
|
|
708
|
+
...selectedCategory,
|
|
709
|
+
color: e.target.value,
|
|
710
|
+
})
|
|
711
|
+
}
|
|
712
|
+
/>
|
|
713
|
+
<Input
|
|
714
|
+
placeholder={t('colorPlaceholder')}
|
|
715
|
+
value={selectedCategory.color || '#000000'}
|
|
716
|
+
onChange={(e) =>
|
|
717
|
+
setSelectedCategory({
|
|
718
|
+
...selectedCategory,
|
|
719
|
+
color: e.target.value,
|
|
720
|
+
})
|
|
721
|
+
}
|
|
722
|
+
/>
|
|
723
|
+
</div>
|
|
724
|
+
</div>
|
|
725
|
+
|
|
726
|
+
<div className="space-y-2">
|
|
727
|
+
<Label htmlFor="icon">{t('icon')}</Label>
|
|
728
|
+
<div className="flex gap-2 items-center">
|
|
729
|
+
<div className="shrink-0">
|
|
730
|
+
{renderIcon(selectedCategory.icon)}
|
|
731
|
+
</div>
|
|
732
|
+
<Input
|
|
733
|
+
id="icon"
|
|
734
|
+
placeholder={t('iconPlaceholder')}
|
|
735
|
+
value={selectedCategory.icon || ''}
|
|
736
|
+
onChange={(e) =>
|
|
737
|
+
setSelectedCategory({
|
|
738
|
+
...selectedCategory,
|
|
739
|
+
icon: e.target.value,
|
|
740
|
+
})
|
|
741
|
+
}
|
|
742
|
+
/>
|
|
743
|
+
</div>
|
|
744
|
+
</div>
|
|
745
|
+
|
|
746
|
+
<div className="space-y-2">
|
|
747
|
+
<Label htmlFor="parent">{t('parentCategory')}</Label>
|
|
748
|
+
<Select
|
|
749
|
+
value={selectedCategory.category_id?.toString() || 'none'}
|
|
750
|
+
onValueChange={(value) =>
|
|
751
|
+
setSelectedCategory({
|
|
752
|
+
...selectedCategory,
|
|
753
|
+
category_id: value === 'none' ? null : Number(value),
|
|
754
|
+
})
|
|
755
|
+
}
|
|
756
|
+
>
|
|
757
|
+
<SelectTrigger className="w-full" id="parent">
|
|
758
|
+
<SelectValue placeholder={t('selectParent')} />
|
|
759
|
+
</SelectTrigger>
|
|
760
|
+
<SelectContent>
|
|
761
|
+
<SelectItem value="none">{t('noneRoot')}</SelectItem>
|
|
762
|
+
{Array.isArray(rootCategories) &&
|
|
763
|
+
rootCategories.map((cat: Category) => (
|
|
764
|
+
<SelectItem
|
|
765
|
+
key={cat.id}
|
|
766
|
+
value={String(cat.category_id)}
|
|
767
|
+
>
|
|
768
|
+
{cat.name}
|
|
769
|
+
</SelectItem>
|
|
770
|
+
))}
|
|
771
|
+
</SelectContent>
|
|
772
|
+
</Select>
|
|
773
|
+
</div>
|
|
774
|
+
|
|
775
|
+
<div className="space-y-2">
|
|
776
|
+
<Label htmlFor="status">{t('status')}</Label>
|
|
777
|
+
<Select
|
|
778
|
+
value={selectedCategory.status}
|
|
779
|
+
onValueChange={(value) =>
|
|
780
|
+
setSelectedCategory({
|
|
781
|
+
...selectedCategory,
|
|
782
|
+
status: value,
|
|
783
|
+
})
|
|
784
|
+
}
|
|
785
|
+
>
|
|
786
|
+
<SelectTrigger className="w-full" id="status">
|
|
787
|
+
<SelectValue placeholder={t('selectStatus')} />
|
|
788
|
+
</SelectTrigger>
|
|
789
|
+
<SelectContent>
|
|
790
|
+
<SelectItem value="active">{t('active')}</SelectItem>
|
|
791
|
+
<SelectItem value="inactive">{t('inactive')}</SelectItem>
|
|
792
|
+
</SelectContent>
|
|
793
|
+
</Select>
|
|
794
|
+
</div>
|
|
795
|
+
</div>
|
|
796
|
+
)}
|
|
797
|
+
|
|
798
|
+
<DialogFooter className="mt-4">
|
|
799
|
+
<Button
|
|
800
|
+
variant="outline"
|
|
801
|
+
onClick={() => {
|
|
802
|
+
setIsEditDialogOpen(false);
|
|
803
|
+
setSelectedCategory(null);
|
|
804
|
+
setIsNewCategory(false);
|
|
805
|
+
}}
|
|
806
|
+
>
|
|
807
|
+
<X className="mr-2 h-4 w-4" />
|
|
808
|
+
{t('cancel')}
|
|
809
|
+
</Button>
|
|
810
|
+
<Button
|
|
811
|
+
onClick={handleSaveCategory}
|
|
812
|
+
disabled={
|
|
813
|
+
!selectedCategory?.slug ||
|
|
814
|
+
!selectedCategory?.locale ||
|
|
815
|
+
!Object.values(selectedCategory.locale).some((l: any) => l.name)
|
|
816
|
+
}
|
|
817
|
+
className="transition-colors hover:bg-primary/90"
|
|
818
|
+
>
|
|
819
|
+
<Save className="mr-2 h-4 w-4" />
|
|
820
|
+
{isNewCategory ? t('createCategoryButton') : t('saveChanges')}
|
|
821
|
+
</Button>
|
|
822
|
+
</DialogFooter>
|
|
823
|
+
</DialogContent>
|
|
824
|
+
</Dialog>
|
|
825
|
+
</div>
|
|
826
|
+
);
|
|
827
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
{
|
|
2
|
+
"Category": {
|
|
3
|
+
"title": "Categories",
|
|
4
|
+
"description": "Manage all system categories",
|
|
5
|
+
"newCategory": "New Category",
|
|
6
|
+
"totalCategories": "Total Categories",
|
|
7
|
+
"active": "Active",
|
|
8
|
+
"inactive": "Inactive",
|
|
9
|
+
"root": "Root",
|
|
10
|
+
"searchPlaceholder": "Search by name or slug...",
|
|
11
|
+
"status": "Status",
|
|
12
|
+
"all": "All",
|
|
13
|
+
"actives": "Active",
|
|
14
|
+
"inactives": "Inactive",
|
|
15
|
+
"hierarchy": "Hierarchy",
|
|
16
|
+
"allHierarchy": "All",
|
|
17
|
+
"rootHierarchy": "Root",
|
|
18
|
+
"slug": "Slug",
|
|
19
|
+
"color": "Color",
|
|
20
|
+
"icon": "Icon",
|
|
21
|
+
"edit": "Edit",
|
|
22
|
+
"delete": "Delete",
|
|
23
|
+
"confirmDelete": "Confirm Deletion",
|
|
24
|
+
"deleteDescription": "Are you sure you want to delete this category? This action cannot be undone.",
|
|
25
|
+
"cancel": "Cancel",
|
|
26
|
+
"noCategoriesFound": "No categories found",
|
|
27
|
+
"adjustFilters": "Try adjusting the filters or create a new category.",
|
|
28
|
+
"createFirstCategory": "Create First Category",
|
|
29
|
+
"editCategory": "Edit Category",
|
|
30
|
+
"newCategoryTitle": "New Category",
|
|
31
|
+
"editDescription": "Edit the category information",
|
|
32
|
+
"createDescription": "Create a new category",
|
|
33
|
+
"language": "Language",
|
|
34
|
+
"selectLanguage": "Select a language",
|
|
35
|
+
"name": "Name",
|
|
36
|
+
"nameRequired": "Name *",
|
|
37
|
+
"namePlaceholder": "Enter the category name",
|
|
38
|
+
"slugRequired": "Slug *",
|
|
39
|
+
"slugPlaceholder": "Enter the slug (e.g., my-category)",
|
|
40
|
+
"colorPlaceholder": "#000000",
|
|
41
|
+
"iconPlaceholder": "Enter the icon name (e.g., Home, Star, Heart)",
|
|
42
|
+
"parentCategory": "Parent Category",
|
|
43
|
+
"selectParent": "Select the parent category",
|
|
44
|
+
"noneRoot": "None (Root)",
|
|
45
|
+
"selectStatus": "Select the status",
|
|
46
|
+
"createCategoryButton": "Create Category",
|
|
47
|
+
"saveChanges": "Save Changes",
|
|
48
|
+
"errorLoading": "Error loading category data.",
|
|
49
|
+
"successUpdate": "Category updated successfully!",
|
|
50
|
+
"successCreate": "Category created successfully!",
|
|
51
|
+
"errorSave": "Error saving category.",
|
|
52
|
+
"successDelete": "Category deleted successfully!",
|
|
53
|
+
"errorDelete": "Error deleting category."
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
{
|
|
2
|
+
"Category": {
|
|
3
|
+
"title": "Categorias",
|
|
4
|
+
"description": "Gerencie todas as categorias do sistema",
|
|
5
|
+
"newCategory": "Nova Categoria",
|
|
6
|
+
"totalCategories": "Total de Categorias",
|
|
7
|
+
"active": "Ativo",
|
|
8
|
+
"inactive": "Inativo",
|
|
9
|
+
"root": "Raiz",
|
|
10
|
+
"searchPlaceholder": "Buscar por nome ou slug...",
|
|
11
|
+
"status": "Status",
|
|
12
|
+
"all": "Todos",
|
|
13
|
+
"actives": "Ativos",
|
|
14
|
+
"inactives": "Inativos",
|
|
15
|
+
"hierarchy": "Hierarquia",
|
|
16
|
+
"allHierarchy": "Todas",
|
|
17
|
+
"rootHierarchy": "Raiz",
|
|
18
|
+
"slug": "Slug",
|
|
19
|
+
"color": "Cor",
|
|
20
|
+
"icon": "Ícone",
|
|
21
|
+
"edit": "Editar",
|
|
22
|
+
"delete": "Excluir",
|
|
23
|
+
"confirmDelete": "Confirmar Exclusão",
|
|
24
|
+
"deleteDescription": "Tem certeza que deseja excluir esta categoria? Esta ação não pode ser desfeita.",
|
|
25
|
+
"cancel": "Cancelar",
|
|
26
|
+
"noCategoriesFound": "Nenhuma categoria encontrada",
|
|
27
|
+
"adjustFilters": "Tente ajustar os filtros ou criar uma nova categoria.",
|
|
28
|
+
"createFirstCategory": "Criar Primeira Categoria",
|
|
29
|
+
"editCategory": "Editar Categoria",
|
|
30
|
+
"newCategoryTitle": "Nova Categoria",
|
|
31
|
+
"editDescription": "Edite as informações da categoria",
|
|
32
|
+
"createDescription": "Crie uma nova categoria",
|
|
33
|
+
"language": "Idioma",
|
|
34
|
+
"selectLanguage": "Selecione um idioma",
|
|
35
|
+
"name": "Nome",
|
|
36
|
+
"nameRequired": "Nome *",
|
|
37
|
+
"namePlaceholder": "Digite o nome da categoria",
|
|
38
|
+
"slugRequired": "Slug *",
|
|
39
|
+
"slugPlaceholder": "Digite o slug (ex: minha-categoria)",
|
|
40
|
+
"colorPlaceholder": "#000000",
|
|
41
|
+
"iconPlaceholder": "Digite o nome do ícone (ex: Home, Star, Heart)",
|
|
42
|
+
"parentCategory": "Categoria Pai",
|
|
43
|
+
"selectParent": "Selecione a categoria pai",
|
|
44
|
+
"noneRoot": "Nenhuma (Raiz)",
|
|
45
|
+
"selectStatus": "Selecione o status",
|
|
46
|
+
"createCategoryButton": "Criar Categoria",
|
|
47
|
+
"saveChanges": "Salvar Alterações",
|
|
48
|
+
"errorLoading": "Erro ao carregar os dados da categoria.",
|
|
49
|
+
"successUpdate": "Categoria atualizada com sucesso!",
|
|
50
|
+
"successCreate": "Categoria criada com sucesso!",
|
|
51
|
+
"errorSave": "Erro ao salvar a categoria.",
|
|
52
|
+
"successDelete": "Categoria excluída com sucesso!",
|
|
53
|
+
"errorDelete": "Erro ao excluir a categoria."
|
|
54
|
+
}
|
|
55
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hed-hog/category",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.187",
|
|
4
4
|
"main": "dist/index.js",
|
|
5
5
|
"types": "dist/index.d.ts",
|
|
6
6
|
"dependencies": {
|
|
@@ -9,8 +9,8 @@
|
|
|
9
9
|
"@nestjs/core": "^11",
|
|
10
10
|
"@nestjs/jwt": "^11",
|
|
11
11
|
"@nestjs/mapped-types": "*",
|
|
12
|
-
"@hed-hog/api-locale": "0.0.11",
|
|
13
12
|
"@hed-hog/api-pagination": "0.0.5",
|
|
13
|
+
"@hed-hog/api-locale": "0.0.11",
|
|
14
14
|
"@hed-hog/api-prisma": "0.0.4",
|
|
15
15
|
"@hed-hog/api": "0.0.3",
|
|
16
16
|
"@hed-hog/core": "0.0.186"
|