@hed-hog/category 0.0.278 → 0.0.285

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -33,7 +33,8 @@ O módulo `@hed-hog/category` é responsável pela gestão de categorias no sist
33
33
  #### GET `/category/root`
34
34
 
35
35
  - **Autenticação:** Necessária (roles: admin, admin-category)
36
- - **Query:** `locale` (string, obrigatório) — código do idioma para as localizações.
36
+ - **Query:**
37
+ - `locale` (string, obrigatório) — código do idioma para as localizações.
37
38
  - **Resposta:** Lista de categorias raiz ativas com traduções no idioma solicitado.
38
39
  - **Erros comuns:**
39
40
  - 400 Bad Request: Locale inválido ou não encontrado.
@@ -57,7 +58,8 @@ O módulo `@hed-hog/category` é responsável pela gestão de categorias no sist
57
58
  - **Autenticação:** Necessária (roles: admin, admin-category)
58
59
  - **Parâmetros:**
59
60
  - `id` (number, obrigatório): ID da categoria.
60
- - **Query:** `locale` (string, obrigatório) — código do idioma para as localizações.
61
+ - **Query:**
62
+ - `locale` (string, obrigatório) — código do idioma para as localizações.
61
63
  - **Resposta:** Objeto categoria com localizações.
62
64
  - **Erros comuns:**
63
65
  - 404 Not Found: Categoria não encontrada.
@@ -67,7 +69,8 @@ O módulo `@hed-hog/category` é responsável pela gestão de categorias no sist
67
69
  - **Autenticação:** Necessária (roles: admin, admin-category)
68
70
  - **Parâmetros:**
69
71
  - `categoryId` (string, obrigatório): ID da categoria pai.
70
- - **Query:** `locale` (string, obrigatório) — código do idioma para as localizações.
72
+ - **Query:**
73
+ - `locale` (string, obrigatório) — código do idioma para as localizações.
71
74
  - **Resposta:** Lista de categorias filhas com localizações.
72
75
  - **Erros comuns:**
73
76
  - 400 Bad Request: Locale inválido.
@@ -79,7 +82,7 @@ O módulo `@hed-hog/category` é responsável pela gestão de categorias no sist
79
82
  - **Query:**
80
83
  - `locale` (string, obrigatório) — código do idioma para as localizações.
81
84
  - `status` (string, opcional): Filtra por status (`active`, `inactive`, `all`).
82
- - `parent` (string, opcional): Filtra por categoria pai (`slug` ou `root` ou `all`).
85
+ - `parent` (string, opcional): Filtra por categoria pai (`slug`, `root` ou `all`).
83
86
  - Parâmetros de paginação via cabeçalho ou query (padrão do módulo de paginação).
84
87
  - **Resposta:** Objeto paginado com categorias e localizações.
85
88
  - **Erros comuns:**
@@ -1,9 +1,12 @@
1
1
  'use client';
2
2
 
3
3
  import {
4
+ EmptyState,
5
+ Page,
4
6
  PageHeader,
5
7
  PaginationFooter,
6
8
  SearchBar,
9
+ StatsCards,
7
10
  } from '@/components/entity-list';
8
11
  import {
9
12
  AlertDialog,
@@ -20,15 +23,14 @@ import { Badge } from '@/components/ui/badge';
20
23
  import { Button } from '@/components/ui/button';
21
24
  import { Card, CardContent } from '@/components/ui/card';
22
25
  import {
23
- Dialog,
24
- DialogContent,
25
- DialogDescription,
26
- DialogFooter,
27
- DialogHeader,
28
- DialogTitle,
29
- } from '@/components/ui/dialog';
26
+ Form,
27
+ FormControl,
28
+ FormField,
29
+ FormItem,
30
+ FormLabel,
31
+ FormMessage,
32
+ } from '@/components/ui/form';
30
33
  import { Input } from '@/components/ui/input';
31
- import { Label } from '@/components/ui/label';
32
34
  import {
33
35
  Select,
34
36
  SelectContent,
@@ -36,8 +38,17 @@ import {
36
38
  SelectTrigger,
37
39
  SelectValue,
38
40
  } from '@/components/ui/select';
41
+ import {
42
+ Sheet,
43
+ SheetContent,
44
+ SheetDescription,
45
+ SheetFooter,
46
+ SheetHeader,
47
+ SheetTitle,
48
+ } from '@/components/ui/sheet';
39
49
  import { useDebounce } from '@/hooks/use-debounce';
40
50
  import { useApp, useQuery } from '@hed-hog/next-app-provider';
51
+ import { zodResolver } from '@hookform/resolvers/zod';
41
52
  import * as TablerIcons from '@tabler/icons-react';
42
53
  import {
43
54
  Edit,
@@ -48,11 +59,12 @@ import {
48
59
  Save,
49
60
  Tag,
50
61
  Trash2,
51
- X,
52
62
  } from 'lucide-react';
53
63
  import { useTranslations } from 'next-intl';
54
64
  import { useEffect, useState } from 'react';
65
+ import { useForm } from 'react-hook-form';
55
66
  import { toast } from 'sonner';
67
+ import { z } from 'zod';
56
68
 
57
69
  type PaginationResult<T> = {
58
70
  data: T[];
@@ -83,10 +95,23 @@ type Locale = {
83
95
  name: string;
84
96
  };
85
97
 
98
+ type CategoryFormValues = {
99
+ name: string;
100
+ slug: string;
101
+ color: string;
102
+ icon: string;
103
+ category_id: string;
104
+ status: 'active' | 'inactive';
105
+ };
106
+
86
107
  export default function CategoryPage() {
87
108
  const t = useTranslations('category.Category');
88
- const [categories, setCategories] = useState<Category[]>([]);
89
- const [selectedCategory, setSelectedCategory] = useState<any | null>(null);
109
+ const [editingCategoryId, setEditingCategoryId] = useState<number | null>(
110
+ null
111
+ );
112
+ const [localeData, setLocaleData] = useState<
113
+ Record<string, { name: string }>
114
+ >({});
90
115
  const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
91
116
  const [isNewCategory, setIsNewCategory] = useState(false);
92
117
  const [searchTerm, setSearchTerm] = useState('');
@@ -99,6 +124,28 @@ export default function CategoryPage() {
99
124
  const [fullLocales, setFullLocales] = useState<any[]>([]);
100
125
  const { request, locales, currentLocaleCode } = useApp();
101
126
 
127
+ const categorySchema = z.object({
128
+ name: z.string().trim().min(1, t('nameRequired')),
129
+ slug: z.string().trim().min(1, t('slugRequired')),
130
+ color: z.string().trim().min(1, t('color')),
131
+ icon: z.string().trim().optional(),
132
+ category_id: z.string(),
133
+ status: z.enum(['active', 'inactive']),
134
+ });
135
+
136
+ const form = useForm<CategoryFormValues>({
137
+ resolver: zodResolver(categorySchema),
138
+ mode: 'onChange',
139
+ defaultValues: {
140
+ name: '',
141
+ slug: '',
142
+ color: '#000000',
143
+ icon: '',
144
+ category_id: 'none',
145
+ status: 'active',
146
+ },
147
+ });
148
+
102
149
  // Buscar locales completos com id
103
150
  useEffect(() => {
104
151
  const fetchLocales = async () => {
@@ -123,10 +170,9 @@ export default function CategoryPage() {
123
170
  }
124
171
  }, [currentLocaleCode]);
125
172
 
126
- const {
127
- data: { data, total },
128
- refetch: refetchCategories,
129
- } = useQuery<PaginationResult<Category>>({
173
+ const { data: categoriesResult, refetch: refetchCategories } = useQuery<
174
+ PaginationResult<Category>
175
+ >({
130
176
  queryKey: [
131
177
  'categories',
132
178
  debouncedSearch,
@@ -148,13 +194,8 @@ export default function CategoryPage() {
148
194
  });
149
195
  return response.data as PaginationResult<Category>;
150
196
  },
151
- initialData: {
152
- data: [],
153
- total: 0,
154
- page: 1,
155
- pageSize: 10,
156
- },
157
197
  });
198
+ const { data: categories = [], total = 0 } = categoriesResult ?? {};
158
199
 
159
200
  const { data: rootCategories = [] } = useQuery<Category[]>({
160
201
  queryKey: ['root-categories'],
@@ -164,7 +205,6 @@ export default function CategoryPage() {
164
205
  });
165
206
  return (response.data || []) as Category[];
166
207
  },
167
- initialData: [],
168
208
  enabled: true,
169
209
  staleTime: 0,
170
210
  refetchOnMount: true,
@@ -180,12 +220,6 @@ export default function CategoryPage() {
180
220
  },
181
221
  });
182
222
 
183
- useEffect(() => {
184
- if (data) {
185
- setCategories(data);
186
- }
187
- }, [data]);
188
-
189
223
  const renderIcon = (iconName?: string) => {
190
224
  const toPascalCase = (str: string) =>
191
225
  str.replace(/(^\w|-\w)/g, (match) =>
@@ -209,22 +243,26 @@ export default function CategoryPage() {
209
243
  };
210
244
 
211
245
  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
-
246
+ const nextLocaleData: Record<string, { name: string }> = {};
221
247
  locales.forEach((locale: Locale) => {
222
- newCategory.locale[locale.code] = {
248
+ nextLocaleData[locale.code] = {
223
249
  name: '',
224
250
  };
225
251
  });
226
252
 
227
- setSelectedCategory(newCategory);
253
+ const initialLocale = currentLocaleCode || locales[0]?.code || '';
254
+
255
+ setEditingCategoryId(null);
256
+ setLocaleData(nextLocaleData);
257
+ setSelectedLocale(initialLocale);
258
+ form.reset({
259
+ name: '',
260
+ slug: '',
261
+ color: '#000000',
262
+ icon: '',
263
+ category_id: 'none',
264
+ status: 'active',
265
+ });
228
266
  setIsNewCategory(true);
229
267
  setIsEditDialogOpen(true);
230
268
  };
@@ -268,9 +306,21 @@ export default function CategoryPage() {
268
306
  }
269
307
  });
270
308
 
271
- setSelectedCategory({
272
- ...categoryData,
273
- locale: localeData,
309
+ const initialLocale =
310
+ selectedLocale || currentLocaleCode || locales[0]?.code || '';
311
+
312
+ setEditingCategoryId(Number(categoryData.id));
313
+ setLocaleData(localeData);
314
+ setSelectedLocale(initialLocale);
315
+ form.reset({
316
+ name: localeData[initialLocale]?.name || '',
317
+ slug: categoryData.slug || '',
318
+ color: categoryData.color || '#000000',
319
+ icon: categoryData.icon || '',
320
+ category_id: categoryData.category_id
321
+ ? String(categoryData.category_id)
322
+ : 'none',
323
+ status: categoryData.status === 'inactive' ? 'inactive' : 'active',
274
324
  });
275
325
  setIsNewCategory(false);
276
326
  setIsEditDialogOpen(true);
@@ -280,22 +330,28 @@ export default function CategoryPage() {
280
330
  }
281
331
  };
282
332
 
283
- const handleSaveCategory = async () => {
284
- if (!selectedCategory) return;
333
+ const handleSaveCategory = form.handleSubmit(async (values) => {
334
+ const mergedLocaleData = {
335
+ ...localeData,
336
+ [selectedLocale]: {
337
+ name: values.name,
338
+ },
339
+ };
285
340
 
286
341
  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,
342
+ locale: mergedLocaleData,
343
+ slug: values.slug,
344
+ category_id:
345
+ values.category_id === 'none' ? null : Number(values.category_id),
346
+ color: values.color,
347
+ icon: values.icon,
348
+ status: values.status,
293
349
  };
294
350
 
295
351
  try {
296
- if (selectedCategory.id) {
352
+ if (editingCategoryId) {
297
353
  await request({
298
- url: `/category/${selectedCategory.id}`,
354
+ url: `/category/${editingCategoryId}`,
299
355
  method: 'PATCH',
300
356
  data: payload,
301
357
  });
@@ -310,13 +366,16 @@ export default function CategoryPage() {
310
366
  }
311
367
 
312
368
  setIsEditDialogOpen(false);
369
+ setEditingCategoryId(null);
370
+ setLocaleData({});
371
+ form.reset();
313
372
  await refetchCategories();
314
373
  await refetchStats();
315
374
  } catch (error) {
316
375
  console.error(error);
317
376
  toast.error(t('errorSave'));
318
377
  }
319
- };
378
+ });
320
379
 
321
380
  const handleDeleteCategory = async (categoryId: number): Promise<void> => {
322
381
  try {
@@ -335,19 +394,16 @@ export default function CategoryPage() {
335
394
 
336
395
  const handleSearchChange = (value: string): void => {
337
396
  setSearchTerm(value);
397
+ setPage(1);
338
398
  };
339
399
 
340
400
  useEffect(() => {
341
- refetchCategories();
342
- refetchStats();
343
- }, [
344
- isEditDialogOpen,
345
- debouncedSearch,
346
- page,
347
- pageSize,
348
- statusFilter,
349
- parentFilter,
350
- ]);
401
+ if (!isEditDialogOpen || !selectedLocale) {
402
+ return;
403
+ }
404
+
405
+ form.setValue('name', localeData[selectedLocale]?.name || '');
406
+ }, [form, isEditDialogOpen, localeData, selectedLocale]);
351
407
 
352
408
  const getStatusBadge = (status: string) => {
353
409
  return status === 'active' ? (
@@ -359,13 +415,41 @@ export default function CategoryPage() {
359
415
  );
360
416
  };
361
417
 
418
+ const statsCards = [
419
+ {
420
+ title: t('totalCategories'),
421
+ value: statsData?.total || 0,
422
+ icon: <Layers className="h-5 w-5" />,
423
+ iconBgColor: 'bg-blue-100',
424
+ iconColor: 'text-blue-600',
425
+ },
426
+ {
427
+ title: t('actives'),
428
+ value: statsData?.totalActive || 0,
429
+ icon: <Tag className="h-5 w-5" />,
430
+ iconBgColor: 'bg-green-100',
431
+ iconColor: 'text-green-600',
432
+ },
433
+ {
434
+ title: t('inactives'),
435
+ value: statsData?.totalInactive || 0,
436
+ icon: <Tag className="h-5 w-5" />,
437
+ iconBgColor: 'bg-orange-100',
438
+ iconColor: 'text-orange-600',
439
+ },
440
+ {
441
+ title: t('root'),
442
+ value: statsData?.totalRoot || 0,
443
+ icon: <FolderTree className="h-5 w-5" />,
444
+ iconBgColor: 'bg-purple-100',
445
+ iconColor: 'text-purple-600',
446
+ },
447
+ ];
448
+
362
449
  return (
363
- <div className="flex flex-col h-screen px-4">
450
+ <Page>
364
451
  <PageHeader
365
- breadcrumbs={[
366
- { label: 'Home', href: '/' },
367
- { label: t('description') },
368
- ]}
452
+ breadcrumbs={[{ label: 'Home', href: '/' }, { label: t('title') }]}
369
453
  actions={[
370
454
  {
371
455
  label: t('newCategory'),
@@ -377,235 +461,163 @@ export default function CategoryPage() {
377
461
  description={t('description')}
378
462
  />
379
463
 
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
- />
464
+ <StatsCards stats={statsCards} />
459
465
 
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>
466
+ <SearchBar
467
+ searchQuery={searchTerm}
468
+ onSearchChange={handleSearchChange}
469
+ onSearch={() => setPage(1)}
470
+ placeholder={t('searchPlaceholder')}
471
+ controls={[
472
+ {
473
+ id: 'status-filter',
474
+ type: 'select',
475
+ value: statusFilter,
476
+ onChange: setStatusFilter,
477
+ placeholder: t('status'),
478
+ options: [
479
+ { value: 'all', label: t('all') },
480
+ { value: 'active', label: t('actives') },
481
+ { value: 'inactive', label: t('inactives') },
482
+ ],
483
+ },
484
+ {
485
+ id: 'parent-filter',
486
+ type: 'select',
487
+ value: parentFilter,
488
+ onChange: setParentFilter,
489
+ placeholder: t('hierarchy'),
490
+ options: [
491
+ { value: 'all', label: t('allHierarchy') },
492
+ { value: 'root', label: t('rootHierarchy') },
493
+ ],
494
+ },
495
+ ]}
496
+ />
480
497
 
481
498
  <div className="space-y-4">
482
499
  {categories.length > 0 ? (
483
- <div className="space-y-4">
500
+ <div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
484
501
  {categories.map((category) => (
485
502
  <Card
486
503
  key={category.id}
487
504
  onDoubleClick={() => handleEditCategory(category)}
488
505
  className="cursor-pointer transition-all duration-200 hover:border-primary/20 hover:shadow-md"
489
506
  >
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>
507
+ <CardContent className="p-4">
508
+ <div className="flex items-start justify-between gap-3">
509
+ <div className="flex min-w-0 items-start gap-3">
510
+ <div
511
+ className="mt-0.5 rounded-full p-2"
512
+ style={{
513
+ backgroundColor: category.color
514
+ ? `${category.color}20`
515
+ : '#00000020',
516
+ }}
517
+ >
518
+ <div style={{ color: category.color || '#000000' }}>
519
+ {renderIcon(category.icon)}
505
520
  </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}
521
+ </div>
522
+ <div className="min-w-0 space-y-2">
523
+ <div className="flex items-center gap-2 flex-wrap">
524
+ <h3 className="truncate text-base font-semibold leading-tight">
525
+ {category.name}
526
+ </h3>
527
+ {getStatusBadge(category.status)}
528
+ </div>
529
+ <div className="space-y-1 text-xs text-muted-foreground">
530
+ <p className="truncate">
531
+ <span className="font-medium text-foreground">
532
+ {t('slug')}:
533
+ </span>{' '}
534
+ {category.slug}
535
+ </p>
536
+ {category.color && (
537
+ <p className="flex items-center gap-2">
538
+ <span className="font-medium text-foreground">
539
+ {t('color')}:
532
540
  </span>
533
- )}
534
- </div>
541
+ <span
542
+ className="inline-block h-3.5 w-3.5 rounded border"
543
+ style={{
544
+ backgroundColor: category.color,
545
+ }}
546
+ />
547
+ <span className="truncate">{category.color}</span>
548
+ </p>
549
+ )}
550
+ {category.icon && (
551
+ <p className="truncate">
552
+ <span className="font-medium text-foreground">
553
+ {t('icon')}:
554
+ </span>{' '}
555
+ {category.icon}
556
+ </p>
557
+ )}
535
558
  </div>
536
559
  </div>
537
560
  </div>
561
+ </div>
538
562
 
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"
563
+ <div className="mt-4 flex items-center justify-end gap-2">
564
+ <Button
565
+ variant="outline"
566
+ size="sm"
567
+ onClick={() => handleEditCategory(category)}
568
+ className="transition-colors hover:border-blue-200 hover:bg-blue-50 hover:text-blue-600 dark:hover:bg-blue-950"
569
+ >
570
+ <Edit className="mr-1 h-4 w-4" />
571
+ {t('edit')}
572
+ </Button>
573
+
574
+ <AlertDialog>
575
+ <AlertDialogTrigger asChild>
576
+ <Button
577
+ variant="outline"
578
+ size="sm"
579
+ className="bg-transparent transition-colors hover:border-red-200 hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-950"
580
+ >
581
+ <Trash2 className="mr-1 h-4 w-4" />
582
+ {t('delete')}
583
+ </Button>
584
+ </AlertDialogTrigger>
585
+ <AlertDialogContent>
586
+ <AlertDialogHeader>
587
+ <AlertDialogTitle>
588
+ {t('confirmDelete')}
589
+ </AlertDialogTitle>
590
+ <AlertDialogDescription>
591
+ {t('deleteDescription')}
592
+ </AlertDialogDescription>
593
+ </AlertDialogHeader>
594
+ <AlertDialogFooter>
595
+ <AlertDialogCancel>{t('cancel')}</AlertDialogCancel>
596
+ <AlertDialogAction
597
+ onClick={() =>
598
+ handleDeleteCategory(Number(category.category_id))
599
+ }
600
+ className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
556
601
  >
557
- <Trash2 className="mr-1 h-4 w-4" />
558
602
  {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>
603
+ </AlertDialogAction>
604
+ </AlertDialogFooter>
605
+ </AlertDialogContent>
606
+ </AlertDialog>
586
607
  </div>
587
608
  </CardContent>
588
609
  </Card>
589
610
  ))}
590
611
  </div>
591
612
  ) : (
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>
613
+ <EmptyState
614
+ icon={<Layers className="h-12 w-12" />}
615
+ title={t('noCategoriesFound')}
616
+ description={t('adjustFilters')}
617
+ actionLabel={t('createFirstCategory')}
618
+ actionIcon={<Plus className="mr-2 h-4 w-4" />}
619
+ onAction={handleNewCategory}
620
+ />
609
621
  )}
610
622
 
611
623
  <PaginationFooter
@@ -613,42 +625,77 @@ export default function CategoryPage() {
613
625
  pageSize={pageSize}
614
626
  totalItems={total}
615
627
  onPageChange={setPage}
616
- onPageSizeChange={setPageSize}
628
+ onPageSizeChange={(nextPageSize) => {
629
+ setPageSize(nextPageSize);
630
+ setPage(1);
631
+ }}
617
632
  pageSizeOptions={[10, 20, 30, 40, 50]}
618
633
  />
619
634
  </div>
620
635
 
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">
636
+ <Sheet
637
+ open={isEditDialogOpen}
638
+ onOpenChange={(open) => {
639
+ setIsEditDialogOpen(open);
640
+ if (!open) {
641
+ setIsNewCategory(false);
642
+ setEditingCategoryId(null);
643
+ setLocaleData({});
644
+ form.reset();
645
+ }
646
+ }}
647
+ >
648
+ <SheetContent className="w-full overflow-y-auto sm:max-w-2xl">
649
+ <SheetHeader>
650
+ <SheetTitle className="flex items-center space-x-2">
625
651
  <Edit className="h-5 w-5" />
626
652
  <span>
627
653
  {isNewCategory ? t('newCategoryTitle') : t('editCategory')}
628
654
  </span>
629
- </DialogTitle>
630
- <DialogDescription>
655
+ </SheetTitle>
656
+ <SheetDescription>
631
657
  {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
- >
658
+ </SheetDescription>
659
+ </SheetHeader>
660
+
661
+ <Form {...form}>
662
+ <form onSubmit={handleSaveCategory} className="space-y-4 px-4">
663
+ <FormItem>
664
+ <FormLabel className="flex items-center gap-2">
642
665
  <Globe className="h-4 w-4" />
643
666
  {t('language')}
644
- </Label>
667
+ </FormLabel>
645
668
  <Select
646
669
  value={selectedLocale}
647
- onValueChange={setSelectedLocale}
670
+ onValueChange={(nextLocale) => {
671
+ const currentName = form.getValues('name');
672
+ const currentLocale = selectedLocale;
673
+ const nextLocaleData = {
674
+ ...localeData,
675
+ };
676
+
677
+ if (currentLocale) {
678
+ nextLocaleData[currentLocale] = {
679
+ name: currentName,
680
+ };
681
+ }
682
+
683
+ if (!nextLocaleData[nextLocale]) {
684
+ nextLocaleData[nextLocale] = {
685
+ name: '',
686
+ };
687
+ }
688
+
689
+ setLocaleData(nextLocaleData);
690
+ setSelectedLocale(nextLocale);
691
+ form.setValue('name', nextLocaleData[nextLocale].name);
692
+ }}
648
693
  >
649
- <SelectTrigger id="locale-select">
650
- <SelectValue placeholder={t('selectLanguage')} />
651
- </SelectTrigger>
694
+ <FormControl>
695
+ <SelectTrigger id="locale-select">
696
+ <SelectValue placeholder={t('selectLanguage')} />
697
+ </SelectTrigger>
698
+ </FormControl>
652
699
  <SelectContent>
653
700
  {locales.map((locale: Locale) => (
654
701
  <SelectItem key={locale.code} value={locale.code}>
@@ -657,171 +704,167 @@ export default function CategoryPage() {
657
704
  ))}
658
705
  </SelectContent>
659
706
  </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}
707
+ </FormItem>
708
+
709
+ <FormField
710
+ control={form.control}
711
+ name="name"
712
+ render={({ field }) => (
713
+ <FormItem>
714
+ <FormLabel>{t('nameRequired')}</FormLabel>
715
+ <FormControl>
716
+ <Input
717
+ {...field}
718
+ placeholder={t('namePlaceholder')}
719
+ onChange={(event) => {
720
+ field.onChange(event.target.value);
721
+ if (selectedLocale) {
722
+ setLocaleData((current) => ({
723
+ ...current,
724
+ [selectedLocale]: {
725
+ name: event.target.value,
726
+ },
727
+ }));
728
+ }
729
+ }}
730
+ />
731
+ </FormControl>
732
+ <FormMessage />
733
+ </FormItem>
734
+ )}
735
+ />
736
+
737
+ <FormField
738
+ control={form.control}
739
+ name="slug"
740
+ render={({ field }) => (
741
+ <FormItem>
742
+ <FormLabel>{t('slugRequired')}</FormLabel>
743
+ <FormControl>
744
+ <Input {...field} placeholder={t('slugPlaceholder')} />
745
+ </FormControl>
746
+ <FormMessage />
747
+ </FormItem>
748
+ )}
749
+ />
750
+
751
+ <FormField
752
+ control={form.control}
753
+ name="color"
754
+ render={({ field }) => (
755
+ <FormItem>
756
+ <FormLabel>{t('color')}</FormLabel>
757
+ <FormControl>
758
+ <div className="flex gap-2">
759
+ <Input
760
+ type="color"
761
+ className="h-10 w-20"
762
+ value={field.value || '#000000'}
763
+ onChange={(event) =>
764
+ field.onChange(event.target.value)
765
+ }
766
+ />
767
+ <Input
768
+ value={field.value || '#000000'}
769
+ onChange={(event) =>
770
+ field.onChange(event.target.value)
771
+ }
772
+ placeholder={t('colorPlaceholder')}
773
+ />
774
+ </div>
775
+ </FormControl>
776
+ <FormMessage />
777
+ </FormItem>
778
+ )}
779
+ />
780
+
781
+ <FormField
782
+ control={form.control}
783
+ name="icon"
784
+ render={({ field }) => (
785
+ <FormItem>
786
+ <FormLabel>{t('icon')}</FormLabel>
787
+ <FormControl>
788
+ <div className="flex items-center gap-2">
789
+ <div className="shrink-0">
790
+ {renderIcon(field.value)}
791
+ </div>
792
+ <Input {...field} placeholder={t('iconPlaceholder')} />
793
+ </div>
794
+ </FormControl>
795
+ <FormMessage />
796
+ </FormItem>
797
+ )}
798
+ />
799
+
800
+ <FormField
801
+ control={form.control}
802
+ name="category_id"
803
+ render={({ field }) => (
804
+ <FormItem>
805
+ <FormLabel>{t('parentCategory')}</FormLabel>
806
+ <Select value={field.value} onValueChange={field.onChange}>
807
+ <FormControl>
808
+ <SelectTrigger className="w-full" id="parent">
809
+ <SelectValue placeholder={t('selectParent')} />
810
+ </SelectTrigger>
811
+ </FormControl>
812
+ <SelectContent>
813
+ <SelectItem value="none">{t('noneRoot')}</SelectItem>
814
+ {Array.isArray(rootCategories) &&
815
+ rootCategories.map((cat: Category) => (
816
+ <SelectItem
817
+ key={cat.id}
818
+ value={String(cat.category_id)}
819
+ >
820
+ {cat.name}
821
+ </SelectItem>
822
+ ))}
823
+ </SelectContent>
824
+ </Select>
825
+ <FormMessage />
826
+ </FormItem>
827
+ )}
828
+ />
829
+
830
+ <FormField
831
+ control={form.control}
832
+ name="status"
833
+ render={({ field }) => (
834
+ <FormItem>
835
+ <FormLabel>{t('status')}</FormLabel>
836
+ <Select value={field.value} onValueChange={field.onChange}>
837
+ <FormControl>
838
+ <SelectTrigger className="w-full" id="status">
839
+ <SelectValue placeholder={t('selectStatus')} />
840
+ </SelectTrigger>
841
+ </FormControl>
842
+ <SelectContent>
843
+ <SelectItem value="active">{t('active')}</SelectItem>
844
+ <SelectItem value="inactive">
845
+ {t('inactive')}
769
846
  </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
- }
847
+ </SelectContent>
848
+ </Select>
849
+ <FormMessage />
850
+ </FormItem>
851
+ )}
852
+ />
853
+
854
+ <SheetFooter className="px-0">
855
+ <Button
856
+ type="submit"
857
+ disabled={!form.formState.isValid}
858
+ className="w-full transition-colors hover:bg-primary/90"
785
859
  >
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>
860
+ <Save className="mr-2 h-4 w-4" />
861
+ {isNewCategory ? t('createCategoryButton') : t('saveChanges')}
862
+ </Button>
863
+ </SheetFooter>
864
+ </form>
865
+ </Form>
866
+ </SheetContent>
867
+ </Sheet>
868
+ </Page>
826
869
  );
827
870
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hed-hog/category",
3
- "version": "0.0.278",
3
+ "version": "0.0.285",
4
4
  "main": "dist/index.js",
5
5
  "types": "dist/index.d.ts",
6
6
  "dependencies": {
@@ -12,7 +12,7 @@
12
12
  "@hed-hog/api-prisma": "0.0.5",
13
13
  "@hed-hog/api": "0.0.4",
14
14
  "@hed-hog/api-locale": "0.0.13",
15
- "@hed-hog/core": "0.0.278",
15
+ "@hed-hog/core": "0.0.285",
16
16
  "@hed-hog/api-pagination": "0.0.6"
17
17
  },
18
18
  "exports": {