@hed-hog/catalog 0.0.276

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.
Files changed (65) hide show
  1. package/dist/catalog-resource.config.d.ts +12 -0
  2. package/dist/catalog-resource.config.d.ts.map +1 -0
  3. package/dist/catalog-resource.config.js +209 -0
  4. package/dist/catalog-resource.config.js.map +1 -0
  5. package/dist/catalog.controller.d.ts +29 -0
  6. package/dist/catalog.controller.d.ts.map +1 -0
  7. package/dist/catalog.controller.js +117 -0
  8. package/dist/catalog.controller.js.map +1 -0
  9. package/dist/catalog.module.d.ts +3 -0
  10. package/dist/catalog.module.d.ts.map +1 -0
  11. package/dist/catalog.module.js +25 -0
  12. package/dist/catalog.module.js.map +1 -0
  13. package/dist/catalog.service.d.ts +36 -0
  14. package/dist/catalog.service.d.ts.map +1 -0
  15. package/dist/catalog.service.js +142 -0
  16. package/dist/catalog.service.js.map +1 -0
  17. package/dist/index.d.ts +2 -0
  18. package/dist/index.d.ts.map +1 -0
  19. package/dist/index.js +18 -0
  20. package/dist/index.js.map +1 -0
  21. package/hedhog/data/menu.yaml +233 -0
  22. package/hedhog/data/role.yaml +7 -0
  23. package/hedhog/data/route.yaml +56 -0
  24. package/hedhog/frontend/app/[resource]/page.tsx.ejs +684 -0
  25. package/hedhog/frontend/app/_components/catalog-nav.tsx.ejs +85 -0
  26. package/hedhog/frontend/app/_lib/catalog-resources.tsx.ejs +569 -0
  27. package/hedhog/frontend/app/dashboard/page.tsx.ejs +566 -0
  28. package/hedhog/frontend/app/page.tsx.ejs +5 -0
  29. package/hedhog/frontend/messages/en.json +293 -0
  30. package/hedhog/frontend/messages/pt.json +293 -0
  31. package/hedhog/table/catalog_affiliate_program.yaml +41 -0
  32. package/hedhog/table/catalog_attribute.yaml +53 -0
  33. package/hedhog/table/catalog_attribute_group.yaml +18 -0
  34. package/hedhog/table/catalog_brand.yaml +34 -0
  35. package/hedhog/table/catalog_category_attribute.yaml +36 -0
  36. package/hedhog/table/catalog_click_event.yaml +50 -0
  37. package/hedhog/table/catalog_comparison.yaml +65 -0
  38. package/hedhog/table/catalog_comparison_highlight.yaml +39 -0
  39. package/hedhog/table/catalog_comparison_item.yaml +30 -0
  40. package/hedhog/table/catalog_content_relation.yaml +42 -0
  41. package/hedhog/table/catalog_import_run.yaml +33 -0
  42. package/hedhog/table/catalog_import_source.yaml +24 -0
  43. package/hedhog/table/catalog_merchant.yaml +29 -0
  44. package/hedhog/table/catalog_offer.yaml +83 -0
  45. package/hedhog/table/catalog_price_history.yaml +34 -0
  46. package/hedhog/table/catalog_product.yaml +76 -0
  47. package/hedhog/table/catalog_product_attribute_value.yaml +60 -0
  48. package/hedhog/table/catalog_product_category.yaml +26 -0
  49. package/hedhog/table/catalog_product_image.yaml +34 -0
  50. package/hedhog/table/catalog_product_score.yaml +38 -0
  51. package/hedhog/table/catalog_product_site.yaml +47 -0
  52. package/hedhog/table/catalog_product_tag.yaml +19 -0
  53. package/hedhog/table/catalog_score_criterion.yaml +37 -0
  54. package/hedhog/table/catalog_seo_page_rule.yaml +51 -0
  55. package/hedhog/table/catalog_similarity_rule.yaml +28 -0
  56. package/hedhog/table/catalog_site.yaml +40 -0
  57. package/hedhog/table/catalog_site_category.yaml +26 -0
  58. package/package.json +40 -0
  59. package/src/catalog-resource.config.ts +218 -0
  60. package/src/catalog.controller.ts +82 -0
  61. package/src/catalog.module.ts +12 -0
  62. package/src/catalog.service.ts +167 -0
  63. package/src/index.ts +1 -0
  64. package/src/language/en.json +4 -0
  65. package/src/language/pt.json +4 -0
@@ -0,0 +1,684 @@
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
+ }