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