@hed-hog/catalog 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.
@@ -1,684 +1,648 @@
1
- 'use client';
2
-
3
- import { CatalogNav } from '../_components/catalog-nav';
4
- import {
5
- catalogResourceMap,
6
- type CatalogFieldDefinition,
7
- type CatalogFilterOptionDefinition,
8
- getCatalogRecordLabel,
9
- } from '../_lib/catalog-resources';
10
- import {
11
- EntityCard,
12
- Page,
13
- PageHeader,
14
- PaginationFooter,
15
- SearchBar,
16
- StatsCards,
17
- } from '@/components/entity-list';
18
- import {
19
- AlertDialog,
20
- AlertDialogAction,
21
- AlertDialogCancel,
22
- AlertDialogContent,
23
- AlertDialogDescription,
24
- AlertDialogFooter,
25
- AlertDialogHeader,
26
- AlertDialogTitle,
27
- } from '@/components/ui/alert-dialog';
28
- import { Badge } from '@/components/ui/badge';
29
- import { Button } from '@/components/ui/button';
30
- import {
31
- Card,
32
- CardContent,
33
- CardDescription,
34
- CardHeader,
35
- CardTitle,
36
- } from '@/components/ui/card';
37
- import {
38
- Dialog,
39
- DialogContent,
40
- DialogDescription,
41
- DialogFooter,
42
- DialogHeader,
43
- DialogTitle,
44
- } from '@/components/ui/dialog';
45
- import { Label } from '@/components/ui/label';
46
- import {
47
- Table,
48
- TableBody,
49
- TableCell,
50
- TableHead,
51
- TableHeader,
52
- TableRow,
53
- } from '@/components/ui/table';
54
- import { Textarea } from '@/components/ui/textarea';
55
- import { useDebounce } from '@/hooks/use-debounce';
56
- import { cn } from '@/lib/utils';
57
- import { useApp, useQuery } from '@hed-hog/next-app-provider';
58
- import { Eye, Pencil, Plus, RefreshCcw, Search, Trash2 } from 'lucide-react';
59
- import Link from 'next/link';
60
- import { useParams } from 'next/navigation';
61
- import { useTranslations } from 'next-intl';
62
- import { useEffect, useMemo, useState } from 'react';
63
- import { toast } from 'sonner';
64
-
65
- type PaginationResult<T> = {
66
- data: T[];
67
- total: number;
68
- page: number;
69
- pageSize: number;
70
- };
71
-
72
- type ResourceStats = {
73
- total: number;
74
- active?: number;
75
- };
76
-
77
- type CatalogRecord = Record<string, unknown>;
78
-
79
- function humanizeValue(value: string) {
80
- return value
81
- .replace(/[_-]+/g, ' ')
82
- .replace(/\b\w/g, (char) => char.toUpperCase());
83
- }
84
-
85
- export default function CatalogResourcePage() {
86
- const params = useParams<{ resource: string }>();
87
- const resource = params?.resource ?? '';
88
- const config = catalogResourceMap.get(resource);
89
- const t = useTranslations('catalog');
90
- const { request, currentLocaleCode } = useApp();
91
- const [search, setSearch] = useState('');
92
- const debouncedSearch = useDebounce(search, 400);
93
- const [filterValue, setFilterValue] = useState('all');
94
- const [page, setPage] = useState(1);
95
- const [pageSize, setPageSize] = useState(12);
96
- const [dialogOpen, setDialogOpen] = useState(false);
97
- const [deleteId, setDeleteId] = useState<number | null>(null);
98
- const [editingRecord, setEditingRecord] = useState<CatalogRecord | null>(
99
- null
100
- );
101
- const [payload, setPayload] = useState('{}');
102
- const [isSubmitting, setIsSubmitting] = useState(false);
103
-
104
- const initialPayload = useMemo(
105
- () => JSON.stringify(config?.template ?? {}, null, 2),
106
- [config]
107
- );
108
- const resourceTitle = config
109
- ? t(`resources.${config.translationKey}.title`)
110
- : t('unsupportedResource');
111
- const resourceDescription = config
112
- ? t(`resources.${config.translationKey}.description`)
113
- : t('resource.unsupportedDescription');
114
- const ResourceIcon = config?.icon;
115
-
116
- const currencyFormatter = useMemo(
117
- () =>
118
- new Intl.NumberFormat(currentLocaleCode === 'pt' ? 'pt-BR' : 'en-US', {
119
- style: 'currency',
120
- currency: 'BRL',
121
- maximumFractionDigits: 0,
122
- }),
123
- [currentLocaleCode]
124
- );
125
-
126
- useEffect(() => {
127
- setPayload(initialPayload);
128
- }, [initialPayload]);
129
-
130
- useEffect(() => {
131
- setPage(1);
132
- }, [debouncedSearch, filterValue]);
133
-
134
- const { data: stats } = useQuery<ResourceStats>({
135
- queryKey: ['catalog-resource-stats', resource],
136
- queryFn: async () => {
137
- if (!resource || !config) {
138
- return { total: 0 };
139
- }
140
-
141
- const response = await request({
142
- url: `/catalog/${resource}/stats`,
143
- });
144
- const statsData = response.data as {
145
- total?: number;
146
- active?: number;
147
- };
148
-
149
- return {
150
- total: Number(statsData.total ?? 0),
151
- active:
152
- statsData.active !== undefined ? Number(statsData.active) : undefined,
153
- };
154
- },
155
- initialData: { total: 0 },
156
- });
157
- const resourceStats = stats as ResourceStats;
158
-
159
- const { data, refetch } = useQuery<PaginationResult<CatalogRecord>>({
160
- queryKey: [
161
- 'catalog-resource',
162
- resource,
163
- debouncedSearch,
164
- filterValue,
165
- page,
166
- pageSize,
167
- ],
168
- queryFn: async () => {
169
- if (!resource || !config) {
170
- return { data: [], total: 0, page: 1, pageSize };
171
- }
172
-
173
- const params: Record<string, string | number> = {
174
- page,
175
- pageSize,
176
- };
177
-
178
- if (debouncedSearch.trim()) {
179
- params.search = debouncedSearch.trim();
180
- }
181
-
182
- if (filterValue !== 'all') {
183
- params[config.primaryFilterField] = filterValue;
184
- }
185
-
186
- const response = await request({
187
- url: `/catalog/${resource}`,
188
- params,
189
- });
190
-
191
- const responseData = response.data as Partial<
192
- PaginationResult<CatalogRecord>
193
- >;
194
-
195
- return {
196
- data: responseData.data ?? [],
197
- total: Number(responseData.total ?? 0),
198
- page: Number(responseData.page ?? page),
199
- pageSize: Number(responseData.pageSize ?? pageSize),
200
- };
201
- },
202
- initialData: { data: [], total: 0, page: 1, pageSize: 12 },
203
- });
204
- const resourceData = data as PaginationResult<CatalogRecord>;
205
-
206
- const safeTranslate = (key: string, fallback: string) => {
207
- try {
208
- return t(key);
209
- } catch {
210
- return fallback;
211
- }
212
- };
213
-
214
- const getFirstValue = (record: CatalogRecord, keys: string[]) => {
215
- for (const key of keys) {
216
- const value = record[key];
217
-
218
- if (
219
- value !== undefined &&
220
- value !== null &&
221
- String(value).trim() !== ''
222
- ) {
223
- return value;
224
- }
225
- }
226
-
227
- return undefined;
228
- };
229
-
230
- const formatFieldValue = (
231
- value: unknown,
232
- field?: CatalogFieldDefinition | { key: string; type?: string }
233
- ) => {
234
- if (value === undefined || value === null || value === '') {
235
- return '-';
236
- }
237
-
238
- if (field?.type === 'boolean') {
239
- return value === true
240
- ? t('resource.booleans.true')
241
- : t('resource.booleans.false');
242
- }
243
-
244
- if (field?.type === 'currency' && typeof value === 'number') {
245
- return currencyFormatter.format(value);
246
- }
247
-
248
- if (typeof value === 'string') {
249
- return safeTranslate(
250
- `resource.valueLabels.${field?.key}.${value}`,
251
- humanizeValue(value)
252
- );
253
- }
254
-
255
- return String(value);
256
- };
257
-
258
- const getCardDescription = (record: CatalogRecord) =>
259
- config
260
- ? config.descriptionFields
261
- .map((key) => record[key])
262
- .filter(
263
- (value): value is string | number =>
264
- value !== undefined &&
265
- value !== null &&
266
- String(value).trim() !== ''
267
- )
268
- .slice(0, 2)
269
- .map((value, index) =>
270
- formatFieldValue(value, {
271
- key: config.descriptionFields[index] ?? '',
272
- })
273
- )
274
- .join(' ')
275
- : '';
276
-
277
- const getBadges = (record: CatalogRecord) =>
278
- config
279
- ? config.badgeFields
280
- .map((key) => {
281
- const value = record[key];
282
-
283
- if (value === undefined || value === null || value === '') {
284
- return null;
285
- }
286
-
287
- return {
288
- label: formatFieldValue(value, { key }),
289
- variant:
290
- value === true || value === 'active' || value === 'published'
291
- ? ('default' as const)
292
- : ('secondary' as const),
293
- };
294
- })
295
- .filter((badge): badge is NonNullable<typeof badge> => badge !== null)
296
- : [];
297
-
298
- const getCardMetadata = (record: CatalogRecord) =>
299
- config
300
- ? config.cardMetadata
301
- .map((field) => {
302
- const rawValue = record[field.key];
303
-
304
- if (
305
- rawValue === undefined ||
306
- rawValue === null ||
307
- rawValue === ''
308
- ) {
309
- return null;
310
- }
311
-
312
- return {
313
- label: t(`resource.fields.${field.labelKey}`),
314
- value: formatFieldValue(rawValue, field),
315
- };
316
- })
317
- .filter((item): item is NonNullable<typeof item> => item !== null)
318
- : [];
319
-
320
- if (!config) {
321
- return (
322
- <Page>
323
- <PageHeader
324
- title={t('unsupportedResource')}
325
- description={t('resource.unsupportedDescription')}
326
- breadcrumbs={[
327
- { label: t('breadcrumbs.home'), href: '/' },
328
- { label: t('breadcrumbs.catalog'), href: '/catalog/dashboard' },
329
- { label: t('resource.notFound') },
330
- ]}
331
- />
332
- <CatalogNav currentHref="/catalog/dashboard" />
333
- <Card>
334
- <CardHeader>
335
- <CardTitle>{t('unsupportedResource')}</CardTitle>
336
- <CardDescription>
337
- {t('resource.unsupportedDescription')}
338
- </CardDescription>
339
- </CardHeader>
340
- <CardContent>
341
- <Button asChild variant="outline">
342
- <Link href="/catalog/dashboard">{t('backToCatalog')}</Link>
343
- </Button>
344
- </CardContent>
345
- </Card>
346
- </Page>
347
- );
348
- }
349
-
350
- const openCreate = () => {
351
- setEditingRecord(null);
352
- setPayload(initialPayload);
353
- setDialogOpen(true);
354
- };
355
-
356
- const openEdit = (record: CatalogRecord) => {
357
- setEditingRecord(record);
358
- setPayload(JSON.stringify(record, null, 2));
359
- setDialogOpen(true);
360
- };
361
-
362
- const submit = async () => {
363
- try {
364
- setIsSubmitting(true);
365
- const body = JSON.parse(payload);
366
-
367
- if (editingRecord?.id) {
368
- await request({
369
- url: `/catalog/${resource}/${editingRecord.id}`,
370
- method: 'PATCH',
371
- data: body,
372
- });
373
- } else {
374
- await request({
375
- url: `/catalog/${resource}`,
376
- method: 'POST',
377
- data: body,
378
- });
379
- }
380
-
381
- toast.success(t('toasts.saveSuccess', { resource: resourceTitle }));
382
- setDialogOpen(false);
383
- await refetch();
384
- } catch (error) {
385
- toast.error(
386
- error instanceof Error ? error.message : t('toasts.saveError')
387
- );
388
- } finally {
389
- setIsSubmitting(false);
390
- }
391
- };
392
-
393
- const handleDelete = async () => {
394
- if (!deleteId) {
395
- return;
396
- }
397
-
398
- try {
399
- await request({
400
- url: `/catalog/${resource}/${deleteId}`,
401
- method: 'DELETE',
402
- });
403
- toast.success(t('toasts.deleteSuccess'));
404
- setDeleteId(null);
405
- await refetch();
406
- } catch (error) {
407
- toast.error(
408
- error instanceof Error ? error.message : t('toasts.deleteError')
409
- );
410
- }
411
- };
412
-
413
- const filterOptions = [
414
- {
415
- label: t('resource.filters.all'),
416
- value: 'all',
417
- },
418
- ...config.primaryFilterOptions.map(
419
- (option: CatalogFilterOptionDefinition) => ({
420
- label: t(
421
- `resource.filterOptions.${config.primaryFilterField}.${option.labelKey}`
422
- ),
423
- value: option.value,
424
- })
425
- ),
426
- ];
427
-
428
- const contextualCount = config.contextualKpi.count(resourceData.data);
429
-
430
- const statsCards = [
431
- {
432
- title: t('resource.kpis.total'),
433
- value: resourceStats.total,
434
- icon: ResourceIcon ? (
435
- <ResourceIcon className="size-5" />
436
- ) : (
437
- <Search className="size-5" />
438
- ),
439
- iconBgColor: 'bg-orange-50',
440
- iconColor: 'text-orange-600',
441
- },
442
- {
443
- title:
444
- typeof resourceStats.active === 'number'
445
- ? t('resource.kpis.active')
446
- : t('resource.kpis.visible'),
447
- value:
448
- typeof resourceStats.active === 'number'
449
- ? resourceStats.active
450
- : resourceData.data.length,
451
- icon: <Eye className="size-5" />,
452
- iconBgColor: 'bg-blue-50',
453
- iconColor: 'text-blue-600',
454
- },
455
- {
456
- title: t(`resource.kpis.${config.contextualKpi.translationKey}`),
457
- value: contextualCount,
458
- icon: <config.contextualKpi.icon className="size-5" />,
459
- iconBgColor: 'bg-emerald-50',
460
- iconColor: 'text-emerald-600',
461
- },
462
- ];
463
-
464
- return (
465
- <Page>
466
- <PageHeader
467
- title={resourceTitle}
468
- description={resourceDescription}
469
- breadcrumbs={[
470
- { label: t('breadcrumbs.home'), href: '/' },
471
- { label: t('breadcrumbs.catalog'), href: '/catalog/dashboard' },
472
- { label: resourceTitle },
473
- ]}
474
- actions={[
475
- {
476
- label: t('refresh'),
477
- onClick: () => {
478
- void refetch();
479
- },
480
- variant: 'outline',
481
- icon: <RefreshCcw className="size-4" />,
482
- },
483
- ]}
484
- />
485
-
486
- <div className="min-w-0 space-y-4 overflow-x-hidden">
487
- <CatalogNav currentHref={config.href} />
488
-
489
- <StatsCards stats={statsCards} className="sm:grid-cols-3" />
490
-
491
- <Card className="min-w-0 overflow-hidden">
492
- <CardHeader className="border-b pb-4">
493
- <CardTitle>{t('resource.listTitle')}</CardTitle>
494
- <CardDescription>{t('resource.listDescription')}</CardDescription>
495
- </CardHeader>
496
- <CardContent className="min-w-0 space-y-6 pt-6">
497
- <div className="flex min-w-0 flex-col gap-3 xl:flex-row xl:items-center">
498
- <SearchBar
499
- searchQuery={search}
500
- onSearchChange={setSearch}
501
- onSearch={() => {
502
- setPage(1);
503
- void refetch();
504
- }}
505
- placeholder={t('searchPlaceholder')}
506
- className="min-w-0 flex-1 flex-wrap"
507
- filters={{
508
- value: filterValue,
509
- options: filterOptions,
510
- onChange: setFilterValue,
511
- placeholder: t(
512
- `resource.filters.${config.primaryFilterField}`
513
- ),
514
- }}
515
- />
516
- <Button onClick={openCreate} className="w-full xl:w-auto">
517
- <Plus className="mr-2 size-4" />
518
- {t('newRecord')}
519
- </Button>
520
- </div>
521
-
522
- {resourceData.data.length === 0 ? (
523
- <div className="rounded-lg border border-dashed px-6 py-12 text-center text-sm text-muted-foreground">
524
- {t('resource.emptyState')}
525
- </div>
526
- ) : config.listVariant === 'table' ? (
527
- <div className="min-w-0 space-y-4">
528
- <div className="overflow-x-auto rounded-md border">
529
- <Table>
530
- <TableHeader>
531
- <TableRow>
532
- {config.tableColumns?.map((column) => (
533
- <TableHead key={column.key}>
534
- {t(`resource.fields.${column.labelKey}`)}
535
- </TableHead>
536
- ))}
537
- <TableHead className="w-[110px] text-right">
538
- {t('resource.fields.actions')}
539
- </TableHead>
540
- </TableRow>
541
- </TableHeader>
542
- <TableBody>
543
- {resourceData.data.map((record) => (
544
- <TableRow key={String(record.id)}>
545
- {config.tableColumns?.map((column) => (
546
- <TableCell key={`${record.id}-${column.key}`}>
547
- {formatFieldValue(record[column.key], column)}
548
- </TableCell>
549
- ))}
550
- <TableCell className="text-right">
551
- <div className="flex justify-end gap-2">
552
- <Button
553
- variant="outline"
554
- size="icon"
555
- onClick={() => openEdit(record)}
556
- >
557
- <Pencil className="size-4" />
558
- </Button>
559
- <Button
560
- variant="destructive"
561
- size="icon"
562
- onClick={() => setDeleteId(Number(record.id))}
563
- >
564
- <Trash2 className="size-4" />
565
- </Button>
566
- </div>
567
- </TableCell>
568
- </TableRow>
569
- ))}
570
- </TableBody>
571
- </Table>
572
- </div>
573
-
574
- <PaginationFooter
575
- currentPage={page}
576
- pageSize={pageSize}
577
- totalItems={resourceData.total}
578
- onPageChange={setPage}
579
- onPageSizeChange={(nextPageSize) => {
580
- setPageSize(nextPageSize);
581
- setPage(1);
582
- }}
583
- />
584
- </div>
585
- ) : (
586
- <div className="min-w-0 space-y-4">
587
- <div className="grid min-w-0 gap-4 md:grid-cols-2 2xl:grid-cols-3">
588
- {resourceData.data.map((record) => (
589
- <EntityCard
590
- key={String(record.id)}
591
- title={String(
592
- getFirstValue(record, config.titleFields) ??
593
- getCatalogRecordLabel(record)
594
- )}
595
- description={getCardDescription(record)}
596
- badges={getBadges(record)}
597
- metadata={getCardMetadata(record)}
598
- actions={[
599
- {
600
- label: t('edit'),
601
- onClick: () => openEdit(record),
602
- variant: 'outline',
603
- icon: <Pencil className="size-4" />,
604
- },
605
- {
606
- label: t('delete'),
607
- onClick: () => setDeleteId(Number(record.id)),
608
- variant: 'destructive',
609
- icon: <Trash2 className="size-4" />,
610
- },
611
- ]}
612
- />
613
- ))}
614
- </div>
615
-
616
- <PaginationFooter
617
- currentPage={page}
618
- pageSize={pageSize}
619
- totalItems={resourceData.total}
620
- onPageChange={setPage}
621
- onPageSizeChange={(nextPageSize) => {
622
- setPageSize(nextPageSize);
623
- setPage(1);
624
- }}
625
- />
626
- </div>
627
- )}
628
- </CardContent>
629
- </Card>
630
-
631
- <Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
632
- <DialogContent className="max-h-[90vh] max-w-3xl overflow-hidden">
633
- <DialogHeader>
634
- <DialogTitle>
635
- {editingRecord ? t('editRecord') : t('newRecord')}
636
- </DialogTitle>
637
- <DialogDescription>{resourceDescription}</DialogDescription>
638
- </DialogHeader>
639
- <div className="min-w-0 space-y-2 overflow-hidden">
640
- <Label htmlFor="payload">{t('payload')}</Label>
641
- <Textarea
642
- id="payload"
643
- value={payload}
644
- onChange={(event) => setPayload(event.target.value)}
645
- className="min-h-[360px] font-mono text-xs"
646
- />
647
- <p className="text-sm text-muted-foreground">
648
- {t('resource.payloadHintDescription')}
649
- </p>
650
- </div>
651
- <DialogFooter>
652
- <Button variant="outline" onClick={() => setDialogOpen(false)}>
653
- {t('cancel')}
654
- </Button>
655
- <Button onClick={submit} disabled={isSubmitting}>
656
- {t('save')}
657
- </Button>
658
- </DialogFooter>
659
- </DialogContent>
660
- </Dialog>
661
-
662
- <AlertDialog
663
- open={deleteId !== null}
664
- onOpenChange={(open) => !open && setDeleteId(null)}
665
- >
666
- <AlertDialogContent>
667
- <AlertDialogHeader>
668
- <AlertDialogTitle>{t('confirmDelete')}</AlertDialogTitle>
669
- <AlertDialogDescription>
670
- {t('confirmDeleteDescription')}
671
- </AlertDialogDescription>
672
- </AlertDialogHeader>
673
- <AlertDialogFooter>
674
- <AlertDialogCancel>{t('cancel')}</AlertDialogCancel>
675
- <AlertDialogAction onClick={handleDelete}>
676
- {t('delete')}
677
- </AlertDialogAction>
678
- </AlertDialogFooter>
679
- </AlertDialogContent>
680
- </AlertDialog>
681
- </div>
682
- </Page>
683
- );
684
- }
1
+ 'use client';
2
+ import {
3
+ catalogResourceMap,
4
+ type CatalogFieldDefinition,
5
+ type CatalogFilterOptionDefinition,
6
+ getCatalogLocalizedText,
7
+ getCatalogRecordLabel,
8
+ } from '../_lib/catalog-resources';
9
+ import { CatalogResourceFormSheet } from '../_components/catalog-resource-form-sheet';
10
+ import {
11
+ EmptyState,
12
+ EntityCard,
13
+ Page,
14
+ PageHeader,
15
+ PaginationFooter,
16
+ SearchBar,
17
+ StatsCards,
18
+ } from '@/components/entity-list';
19
+ import {
20
+ AlertDialog,
21
+ AlertDialogAction,
22
+ AlertDialogCancel,
23
+ AlertDialogContent,
24
+ AlertDialogDescription,
25
+ AlertDialogFooter,
26
+ AlertDialogHeader,
27
+ AlertDialogTitle,
28
+ } from '@/components/ui/alert-dialog';
29
+ import { Button } from '@/components/ui/button';
30
+ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
31
+ import {
32
+ Table,
33
+ TableBody,
34
+ TableCell,
35
+ TableHead,
36
+ TableHeader,
37
+ TableRow,
38
+ } from '@/components/ui/table';
39
+ import { useDebounce } from '@/hooks/use-debounce';
40
+ import { useApp, useQuery } from '@hed-hog/next-app-provider';
41
+ import { Eye, Pencil, Plus, Search, Trash2 } from 'lucide-react';
42
+ import Link from 'next/link';
43
+ import { useParams } from 'next/navigation';
44
+ import { useTranslations } from 'next-intl';
45
+ import { useMemo, useState } from 'react';
46
+ import { toast } from 'sonner';
47
+
48
+ type PaginationResult<T> = {
49
+ data: T[];
50
+ total: number;
51
+ page: number;
52
+ pageSize: number;
53
+ };
54
+
55
+ type ResourceStats = {
56
+ total: number;
57
+ active?: number;
58
+ };
59
+
60
+ type CatalogRecord = Record<string, unknown>;
61
+
62
+ function humanizeValue(value: string) {
63
+ return value
64
+ .replace(/[_-]+/g, ' ')
65
+ .replace(/\b\w/g, (char) => char.toUpperCase());
66
+ }
67
+
68
+ export default function CatalogResourcePage() {
69
+ const params = useParams<{ resource: string }>();
70
+ const resource = params?.resource ?? '';
71
+ const config = catalogResourceMap.get(resource);
72
+ const t = useTranslations('catalog');
73
+ const { accessToken, request, currentLocaleCode } = useApp();
74
+ const [search, setSearch] = useState('');
75
+ const debouncedSearch = useDebounce(search, 400);
76
+ const [filterValue, setFilterValue] = useState('all');
77
+ const [page, setPage] = useState(1);
78
+ const [pageSize, setPageSize] = useState(12);
79
+ const [sheetOpen, setSheetOpen] = useState(false);
80
+ const [deleteId, setDeleteId] = useState<number | null>(null);
81
+ const [editingRecordId, setEditingRecordId] = useState<number | null>(null);
82
+ const resourceTitle = config
83
+ ? t(`resources.${config.translationKey}.title`)
84
+ : t('unsupportedResource');
85
+ const resourceDescription = config
86
+ ? t(`resources.${config.translationKey}.description`)
87
+ : t('resource.unsupportedDescription');
88
+ const ResourceIcon = config?.icon;
89
+
90
+ const currencyFormatter = useMemo(
91
+ () =>
92
+ new Intl.NumberFormat(currentLocaleCode === 'pt' ? 'pt-BR' : 'en-US', {
93
+ style: 'currency',
94
+ currency: 'BRL',
95
+ maximumFractionDigits: 0,
96
+ }),
97
+ [currentLocaleCode]
98
+ );
99
+ const isAppReady = accessToken.trim().length > 0;
100
+ const translatableValueFields = useMemo(
101
+ () =>
102
+ new Set([
103
+ 'status',
104
+ 'comparison_status',
105
+ 'availability_status',
106
+ 'site_type',
107
+ 'merchant_type',
108
+ 'network_type',
109
+ 'commission_type',
110
+ 'page_type',
111
+ 'canonical_strategy',
112
+ 'source_type',
113
+ 'data_type',
114
+ 'comparison_mode',
115
+ 'facet_mode',
116
+ ]),
117
+ []
118
+ );
119
+
120
+ const { data: stats } = useQuery<ResourceStats>({
121
+ queryKey: [
122
+ 'catalog-resource-stats',
123
+ resource,
124
+ accessToken,
125
+ currentLocaleCode,
126
+ ],
127
+ queryFn: async () => {
128
+ if (!resource || !config) {
129
+ return { total: 0 };
130
+ }
131
+
132
+ const response = await request({
133
+ url: `/catalog/${resource}/stats`,
134
+ });
135
+ const statsData = response.data as {
136
+ total?: number;
137
+ active?: number;
138
+ };
139
+
140
+ return {
141
+ total: Number(statsData.total ?? 0),
142
+ active:
143
+ statsData.active !== undefined ? Number(statsData.active) : undefined,
144
+ };
145
+ },
146
+ enabled: Boolean(resource && config && isAppReady),
147
+ });
148
+ const resourceStats = (stats ?? { total: 0 }) as ResourceStats;
149
+
150
+ const { data, refetch } = useQuery<PaginationResult<CatalogRecord>>({
151
+ queryKey: [
152
+ 'catalog-resource',
153
+ resource,
154
+ accessToken,
155
+ currentLocaleCode,
156
+ debouncedSearch,
157
+ filterValue,
158
+ page,
159
+ pageSize,
160
+ ],
161
+ queryFn: async () => {
162
+ if (!resource || !config) {
163
+ return { data: [], total: 0, page: 1, pageSize };
164
+ }
165
+
166
+ const params: Record<string, string | number> = {
167
+ page,
168
+ pageSize,
169
+ };
170
+
171
+ if (debouncedSearch.trim()) {
172
+ params.search = debouncedSearch.trim();
173
+ }
174
+
175
+ if (filterValue !== 'all') {
176
+ params[config.primaryFilterField] = filterValue;
177
+ }
178
+
179
+ const response = await request({
180
+ url: `/catalog/${resource}`,
181
+ params,
182
+ });
183
+
184
+ const responseData = response.data as Partial<
185
+ PaginationResult<CatalogRecord>
186
+ >;
187
+
188
+ return {
189
+ data: responseData.data ?? [],
190
+ total: Number(responseData.total ?? 0),
191
+ page: Number(responseData.page ?? page),
192
+ pageSize: Number(responseData.pageSize ?? pageSize),
193
+ };
194
+ },
195
+ enabled: Boolean(resource && config && isAppReady),
196
+ });
197
+ const resourceData = (data ?? {
198
+ data: [],
199
+ total: 0,
200
+ page: 1,
201
+ pageSize: 12,
202
+ }) as PaginationResult<CatalogRecord>;
203
+
204
+ const safeTranslate = (key: string, fallback: string) => {
205
+ const translated = t(key);
206
+
207
+ if (translated === key) {
208
+ return fallback;
209
+ }
210
+
211
+ return translated;
212
+ };
213
+
214
+ const resolveDynamicValueLabel = (fieldKey: string, value: string) => {
215
+ if (!translatableValueFields.has(fieldKey)) {
216
+ return value;
217
+ }
218
+
219
+ return safeTranslate(
220
+ `resource.valueLabels.${fieldKey}.${value}`,
221
+ humanizeValue(value)
222
+ );
223
+ };
224
+
225
+ const getFirstValue = (record: CatalogRecord, keys: string[]) => {
226
+ for (const key of keys) {
227
+ const value = record[key];
228
+
229
+ if (
230
+ value !== undefined &&
231
+ value !== null &&
232
+ String(value).trim() !== ''
233
+ ) {
234
+ return value;
235
+ }
236
+ }
237
+
238
+ return undefined;
239
+ };
240
+
241
+ const formatFieldValue = (
242
+ value: unknown,
243
+ field?: CatalogFieldDefinition | { key: string; type?: string }
244
+ ) => {
245
+ if (value === undefined || value === null || value === '') {
246
+ return '-';
247
+ }
248
+
249
+ if (field?.type === 'boolean') {
250
+ return value === true
251
+ ? t('resource.booleans.true')
252
+ : t('resource.booleans.false');
253
+ }
254
+
255
+ if (field?.type === 'currency' && typeof value === 'number') {
256
+ return currencyFormatter.format(value);
257
+ }
258
+
259
+ if (typeof value === 'string') {
260
+ return field?.key ? resolveDynamicValueLabel(field.key, value) : value;
261
+ }
262
+
263
+ return String(value);
264
+ };
265
+
266
+ const getCardDescription = (record: CatalogRecord) =>
267
+ config
268
+ ? config.descriptionFields
269
+ .map((key) => record[key])
270
+ .filter(
271
+ (value): value is string | number =>
272
+ value !== undefined &&
273
+ value !== null &&
274
+ String(value).trim() !== ''
275
+ )
276
+ .slice(0, 2)
277
+ .map((value, index) =>
278
+ formatFieldValue(value, {
279
+ key: config.descriptionFields[index] ?? '',
280
+ })
281
+ )
282
+ .join(' • ')
283
+ : '';
284
+
285
+ const getBadges = (record: CatalogRecord) =>
286
+ config
287
+ ? config.badgeFields
288
+ .map((key) => {
289
+ const value = record[key];
290
+
291
+ if (value === undefined || value === null || value === '') {
292
+ return null;
293
+ }
294
+
295
+ return {
296
+ label: formatFieldValue(value, { key }),
297
+ variant:
298
+ value === true || value === 'active' || value === 'published'
299
+ ? ('default' as const)
300
+ : ('secondary' as const),
301
+ };
302
+ })
303
+ .filter((badge): badge is NonNullable<typeof badge> => badge !== null)
304
+ : [];
305
+
306
+ const getCardMetadata = (record: CatalogRecord) =>
307
+ config
308
+ ? config.cardMetadata
309
+ .map((field) => {
310
+ const rawValue = record[field.key];
311
+
312
+ if (
313
+ rawValue === undefined ||
314
+ rawValue === null ||
315
+ rawValue === ''
316
+ ) {
317
+ return null;
318
+ }
319
+
320
+ return {
321
+ label: t(`resource.fields.${field.labelKey}`),
322
+ value: formatFieldValue(rawValue, field),
323
+ };
324
+ })
325
+ .filter((item): item is NonNullable<typeof item> => item !== null)
326
+ : [];
327
+
328
+ if (!config) {
329
+ return (
330
+ <Page>
331
+ <PageHeader
332
+ title={t('unsupportedResource')}
333
+ description={t('resource.unsupportedDescription')}
334
+ breadcrumbs={[
335
+ { label: t('breadcrumbs.home'), href: '/' },
336
+ { label: t('breadcrumbs.catalog'), href: '/catalog/dashboard' },
337
+ { label: t('resource.notFound') },
338
+ ]}
339
+ />
340
+ <Card>
341
+ <CardHeader>
342
+ <CardTitle>{t('unsupportedResource')}</CardTitle>
343
+ <CardDescription>
344
+ {t('resource.unsupportedDescription')}
345
+ </CardDescription>
346
+ </CardHeader>
347
+ <CardContent>
348
+ <Button asChild variant="outline">
349
+ <Link href="/catalog/dashboard">{t('backToCatalog')}</Link>
350
+ </Button>
351
+ </CardContent>
352
+ </Card>
353
+ </Page>
354
+ );
355
+ }
356
+
357
+ const openCreate = () => {
358
+ setEditingRecordId(null);
359
+ setSheetOpen(true);
360
+ };
361
+
362
+ const openEdit = (record: CatalogRecord) => {
363
+ setEditingRecordId(Number(record.id));
364
+ setSheetOpen(true);
365
+ };
366
+
367
+ const handleDelete = async () => {
368
+ if (!deleteId) {
369
+ return;
370
+ }
371
+
372
+ try {
373
+ await request({
374
+ url: `/catalog/${resource}/${deleteId}`,
375
+ method: 'DELETE',
376
+ });
377
+ toast.success(t('toasts.deleteSuccess'));
378
+ setDeleteId(null);
379
+ await refetch();
380
+ } catch (error) {
381
+ toast.error(
382
+ error instanceof Error ? error.message : t('toasts.deleteError')
383
+ );
384
+ }
385
+ };
386
+
387
+ const filterOptions = [
388
+ {
389
+ label: t('resource.filters.all'),
390
+ value: 'all',
391
+ },
392
+ ...config.primaryFilterOptions.map(
393
+ (option: CatalogFilterOptionDefinition) => ({
394
+ label: t(
395
+ `resource.filterOptions.${config.primaryFilterField}.${option.labelKey}`
396
+ ),
397
+ value: option.value,
398
+ })
399
+ ),
400
+ ];
401
+
402
+ const contextualCount = config.contextualKpi.count(resourceData.data);
403
+
404
+ const statsCards = [
405
+ {
406
+ title: t('resource.kpis.total'),
407
+ value: resourceStats.total,
408
+ icon: ResourceIcon ? (
409
+ <ResourceIcon className="size-5" />
410
+ ) : (
411
+ <Search className="size-5" />
412
+ ),
413
+ iconBgColor: 'bg-orange-50',
414
+ iconColor: 'text-orange-600',
415
+ },
416
+ {
417
+ title:
418
+ typeof resourceStats.active === 'number'
419
+ ? t('resource.kpis.active')
420
+ : t('resource.kpis.visible'),
421
+ value:
422
+ typeof resourceStats.active === 'number'
423
+ ? resourceStats.active
424
+ : resourceData.data.length,
425
+ icon: <Eye className="size-5" />,
426
+ iconBgColor: 'bg-blue-50',
427
+ iconColor: 'text-blue-600',
428
+ },
429
+ {
430
+ title: t(`resource.kpis.${config.contextualKpi.translationKey}`),
431
+ value: contextualCount,
432
+ icon: <config.contextualKpi.icon className="size-5" />,
433
+ iconBgColor: 'bg-emerald-50',
434
+ iconColor: 'text-emerald-600',
435
+ },
436
+ ];
437
+
438
+ return (
439
+ <Page>
440
+ <PageHeader
441
+ title={resourceTitle}
442
+ description={resourceDescription}
443
+ breadcrumbs={[
444
+ { label: t('breadcrumbs.home'), href: '/' },
445
+ { label: t('breadcrumbs.catalog'), href: '/catalog/dashboard' },
446
+ { label: resourceTitle },
447
+ ]}
448
+ actions={[
449
+ {
450
+ label: getCatalogLocalizedText(
451
+ config.createActionLabel,
452
+ currentLocaleCode
453
+ ),
454
+ onClick: () => {
455
+ openCreate();
456
+ },
457
+ icon: <Plus className="size-4" />,
458
+ },
459
+ ]}
460
+ />
461
+
462
+ <div className="min-w-0 space-y-4 overflow-x-hidden">
463
+ <StatsCards stats={statsCards} className="sm:grid-cols-3" />
464
+
465
+ <div className="min-w-0 space-y-4">
466
+ <SearchBar
467
+ searchQuery={search}
468
+ onSearchChange={(value) => {
469
+ setSearch(value);
470
+ setPage(1);
471
+ }}
472
+ onSearch={() => {
473
+ setPage(1);
474
+ void refetch();
475
+ }}
476
+ placeholder={t('searchPlaceholder')}
477
+ className="min-w-0"
478
+ filters={{
479
+ value: filterValue,
480
+ options: filterOptions,
481
+ onChange: (value) => {
482
+ setFilterValue(value);
483
+ setPage(1);
484
+ },
485
+ placeholder: t(`resource.filters.${config.primaryFilterField}`),
486
+ }}
487
+ />
488
+
489
+ {resourceData.data.length === 0 ? (
490
+ <EmptyState
491
+ icon={
492
+ ResourceIcon ? (
493
+ <ResourceIcon className="h-12 w-12" />
494
+ ) : (
495
+ <Search className="h-12 w-12" />
496
+ )
497
+ }
498
+ title={t('resource.emptyStateTitle')}
499
+ description={t('resource.emptyStateDescription')}
500
+ actionLabel={t('resource.emptyStateAction')}
501
+ actionIcon={<Plus className="mr-2 size-4" />}
502
+ onAction={openCreate}
503
+ />
504
+ ) : config.listVariant === 'table' ? (
505
+ <div className="min-w-0 space-y-4">
506
+ <div className="overflow-x-auto rounded-md border">
507
+ <Table>
508
+ <TableHeader>
509
+ <TableRow>
510
+ {config.tableColumns?.map((column) => (
511
+ <TableHead key={column.key}>
512
+ {t(`resource.fields.${column.labelKey}`)}
513
+ </TableHead>
514
+ ))}
515
+ <TableHead className="w-[110px] text-right">
516
+ {t('resource.fields.actions')}
517
+ </TableHead>
518
+ </TableRow>
519
+ </TableHeader>
520
+ <TableBody>
521
+ {resourceData.data.map((record) => (
522
+ <TableRow key={String(record.id)}>
523
+ {config.tableColumns?.map((column) => (
524
+ <TableCell key={`${record.id}-${column.key}`}>
525
+ {formatFieldValue(record[column.key], column)}
526
+ </TableCell>
527
+ ))}
528
+ <TableCell className="text-right">
529
+ <div className="flex justify-end gap-2">
530
+ <Button
531
+ variant="outline"
532
+ size="icon"
533
+ onClick={() => openEdit(record)}
534
+ >
535
+ <Pencil className="size-4" />
536
+ </Button>
537
+ <Button
538
+ variant="destructive"
539
+ size="icon"
540
+ onClick={() => setDeleteId(Number(record.id))}
541
+ >
542
+ <Trash2 className="size-4" />
543
+ </Button>
544
+ </div>
545
+ </TableCell>
546
+ </TableRow>
547
+ ))}
548
+ </TableBody>
549
+ </Table>
550
+ </div>
551
+
552
+ <PaginationFooter
553
+ currentPage={page}
554
+ pageSize={pageSize}
555
+ totalItems={resourceData.total}
556
+ onPageChange={setPage}
557
+ onPageSizeChange={(nextPageSize) => {
558
+ setPageSize(nextPageSize);
559
+ setPage(1);
560
+ }}
561
+ />
562
+ </div>
563
+ ) : (
564
+ <div className="min-w-0 space-y-4">
565
+ <div className="grid min-w-0 gap-4 md:grid-cols-2 2xl:grid-cols-3">
566
+ {resourceData.data.map((record) => (
567
+ <EntityCard
568
+ key={String(record.id)}
569
+ title={String(
570
+ getFirstValue(record, config.titleFields) ??
571
+ getCatalogRecordLabel(record)
572
+ )}
573
+ description={getCardDescription(record)}
574
+ badges={getBadges(record)}
575
+ metadata={getCardMetadata(record)}
576
+ actions={[
577
+ {
578
+ label: t('edit'),
579
+ onClick: () => openEdit(record),
580
+ variant: 'outline',
581
+ icon: <Pencil className="size-4" />,
582
+ },
583
+ {
584
+ label: t('delete'),
585
+ onClick: () => setDeleteId(Number(record.id)),
586
+ variant: 'destructive',
587
+ icon: <Trash2 className="size-4" />,
588
+ },
589
+ ]}
590
+ />
591
+ ))}
592
+ </div>
593
+
594
+ <PaginationFooter
595
+ currentPage={page}
596
+ pageSize={pageSize}
597
+ totalItems={resourceData.total}
598
+ onPageChange={setPage}
599
+ onPageSizeChange={(nextPageSize) => {
600
+ setPageSize(nextPageSize);
601
+ setPage(1);
602
+ }}
603
+ />
604
+ </div>
605
+ )}
606
+ </div>
607
+ <CatalogResourceFormSheet
608
+ open={sheetOpen}
609
+ onOpenChange={(open) => {
610
+ setSheetOpen(open);
611
+ if (!open) {
612
+ setEditingRecordId(null);
613
+ }
614
+ }}
615
+ resource={resource}
616
+ resourceConfig={config}
617
+ resourceTitle={resourceTitle}
618
+ resourceDescription={resourceDescription}
619
+ recordId={editingRecordId}
620
+ onSuccess={async () => {
621
+ setEditingRecordId(null);
622
+ await refetch();
623
+ }}
624
+ />
625
+
626
+ <AlertDialog
627
+ open={deleteId !== null}
628
+ onOpenChange={(open) => !open && setDeleteId(null)}
629
+ >
630
+ <AlertDialogContent>
631
+ <AlertDialogHeader>
632
+ <AlertDialogTitle>{t('confirmDelete')}</AlertDialogTitle>
633
+ <AlertDialogDescription>
634
+ {t('confirmDeleteDescription')}
635
+ </AlertDialogDescription>
636
+ </AlertDialogHeader>
637
+ <AlertDialogFooter>
638
+ <AlertDialogCancel>{t('cancel')}</AlertDialogCancel>
639
+ <AlertDialogAction onClick={handleDelete}>
640
+ {t('delete')}
641
+ </AlertDialogAction>
642
+ </AlertDialogFooter>
643
+ </AlertDialogContent>
644
+ </AlertDialog>
645
+ </div>
646
+ </Page>
647
+ );
648
+ }