@hed-hog/catalog 0.0.297 → 0.0.298
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/hedhog/frontend/app/[resource]/page.tsx.ejs +391 -391
- package/hedhog/frontend/app/_components/catalog-ai-form-assist-dialog.tsx.ejs +340 -340
- package/hedhog/frontend/app/_components/catalog-resource-form-sheet.tsx.ejs +907 -907
- package/hedhog/frontend/app/_lib/catalog-resources.tsx.ejs +929 -929
- package/hedhog/frontend/messages/en.json +389 -389
- package/hedhog/frontend/messages/pt.json +389 -389
- package/package.json +7 -7
|
@@ -16,8 +16,8 @@ import {
|
|
|
16
16
|
SearchBar,
|
|
17
17
|
StatsCards,
|
|
18
18
|
} from '@/components/entity-list';
|
|
19
|
-
import {
|
|
20
|
-
AlertDialog,
|
|
19
|
+
import {
|
|
20
|
+
AlertDialog,
|
|
21
21
|
AlertDialogAction,
|
|
22
22
|
AlertDialogCancel,
|
|
23
23
|
AlertDialogContent,
|
|
@@ -25,19 +25,19 @@ import {
|
|
|
25
25
|
AlertDialogFooter,
|
|
26
26
|
AlertDialogHeader,
|
|
27
27
|
AlertDialogTitle,
|
|
28
|
-
} from '@/components/ui/alert-dialog';
|
|
29
|
-
import { Badge } from '@/components/ui/badge';
|
|
30
|
-
import { Button } from '@/components/ui/button';
|
|
31
|
-
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
|
32
|
-
import {
|
|
33
|
-
Dialog,
|
|
34
|
-
DialogContent,
|
|
35
|
-
DialogDescription,
|
|
36
|
-
DialogHeader,
|
|
37
|
-
DialogTitle,
|
|
38
|
-
} from '@/components/ui/dialog';
|
|
39
|
-
import {
|
|
40
|
-
Table,
|
|
28
|
+
} from '@/components/ui/alert-dialog';
|
|
29
|
+
import { Badge } from '@/components/ui/badge';
|
|
30
|
+
import { Button } from '@/components/ui/button';
|
|
31
|
+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
|
32
|
+
import {
|
|
33
|
+
Dialog,
|
|
34
|
+
DialogContent,
|
|
35
|
+
DialogDescription,
|
|
36
|
+
DialogHeader,
|
|
37
|
+
DialogTitle,
|
|
38
|
+
} from '@/components/ui/dialog';
|
|
39
|
+
import {
|
|
40
|
+
Table,
|
|
41
41
|
TableBody,
|
|
42
42
|
TableCell,
|
|
43
43
|
TableHead,
|
|
@@ -65,327 +65,327 @@ type ResourceStats = {
|
|
|
65
65
|
active?: number;
|
|
66
66
|
};
|
|
67
67
|
|
|
68
|
-
type CatalogRecord = Record<string, unknown>;
|
|
69
|
-
type ProductAttributePayload = {
|
|
70
|
-
groups?: Array<{
|
|
71
|
-
id: number | null;
|
|
72
|
-
name: string;
|
|
73
|
-
slug: string;
|
|
74
|
-
attributes: Array<
|
|
75
|
-
CatalogRecord & {
|
|
76
|
-
options?: CatalogRecord[];
|
|
77
|
-
value?: CatalogRecord | null;
|
|
78
|
-
category_attribute?: CatalogRecord;
|
|
79
|
-
}
|
|
80
|
-
>;
|
|
81
|
-
}>;
|
|
82
|
-
};
|
|
83
|
-
|
|
84
|
-
function humanizeValue(value: string) {
|
|
68
|
+
type CatalogRecord = Record<string, unknown>;
|
|
69
|
+
type ProductAttributePayload = {
|
|
70
|
+
groups?: Array<{
|
|
71
|
+
id: number | null;
|
|
72
|
+
name: string;
|
|
73
|
+
slug: string;
|
|
74
|
+
attributes: Array<
|
|
75
|
+
CatalogRecord & {
|
|
76
|
+
options?: CatalogRecord[];
|
|
77
|
+
value?: CatalogRecord | null;
|
|
78
|
+
category_attribute?: CatalogRecord;
|
|
79
|
+
}
|
|
80
|
+
>;
|
|
81
|
+
}>;
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
function humanizeValue(value: string) {
|
|
85
85
|
return value
|
|
86
86
|
.replace(/[_-]+/g, ' ')
|
|
87
|
-
.replace(/\b\w/g, (char) => char.toUpperCase());
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
function CatalogProductPreviewDialog({
|
|
91
|
-
open,
|
|
92
|
-
onOpenChange,
|
|
93
|
-
productId,
|
|
94
|
-
}: {
|
|
95
|
-
open: boolean;
|
|
96
|
-
onOpenChange: (open: boolean) => void;
|
|
97
|
-
productId: number | null;
|
|
98
|
-
}) {
|
|
99
|
-
const { request, currentLocaleCode } = useApp();
|
|
100
|
-
const t = useTranslations('catalog');
|
|
101
|
-
const isPt = currentLocaleCode?.startsWith('pt');
|
|
102
|
-
const { data: product, isLoading: isLoadingProduct } = useQuery<CatalogRecord | null>({
|
|
103
|
-
queryKey: ['catalog-product-preview', productId],
|
|
104
|
-
queryFn: async () => {
|
|
105
|
-
if (!productId) {
|
|
106
|
-
return null;
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
const response = await request({
|
|
110
|
-
url: `/catalog/products/${productId}`,
|
|
111
|
-
});
|
|
112
|
-
|
|
113
|
-
return (response.data ?? null) as CatalogRecord | null;
|
|
114
|
-
},
|
|
115
|
-
enabled: open && Boolean(productId),
|
|
116
|
-
});
|
|
117
|
-
const { data: attributes, isLoading: isLoadingAttributes } =
|
|
118
|
-
useQuery<ProductAttributePayload | null>({
|
|
119
|
-
queryKey: ['catalog-product-preview-attributes', productId],
|
|
120
|
-
queryFn: async () => {
|
|
121
|
-
if (!productId) {
|
|
122
|
-
return null;
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
const response = await request({
|
|
126
|
-
url: `/catalog/products/${productId}/attributes`,
|
|
127
|
-
});
|
|
128
|
-
|
|
129
|
-
return (response.data ?? null) as ProductAttributePayload | null;
|
|
130
|
-
},
|
|
131
|
-
enabled: open && Boolean(productId),
|
|
132
|
-
});
|
|
133
|
-
|
|
134
|
-
const formatAttributeValue = (attribute: CatalogRecord & {
|
|
135
|
-
options?: CatalogRecord[];
|
|
136
|
-
value?: CatalogRecord | null;
|
|
137
|
-
}) => {
|
|
138
|
-
const value = (attribute.value ?? null) as CatalogRecord | null;
|
|
139
|
-
|
|
140
|
-
if (!value) {
|
|
141
|
-
return '-';
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
const dataType = String(attribute.data_type ?? 'text');
|
|
145
|
-
|
|
146
|
-
if (dataType === 'option') {
|
|
147
|
-
const option = (attribute.options ?? []).find(
|
|
148
|
-
(item) => Number(item.id ?? 0) === Number(value.attribute_option_id ?? 0),
|
|
149
|
-
);
|
|
150
|
-
return String(option?.label ?? option?.option_value ?? value.value_text ?? '-');
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
if (dataType === 'number') {
|
|
154
|
-
const numberValue =
|
|
155
|
-
value.value_number === null || value.value_number === undefined
|
|
156
|
-
? null
|
|
157
|
-
: Number(value.value_number);
|
|
158
|
-
|
|
159
|
-
if (numberValue === null) {
|
|
160
|
-
return '-';
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
const unit = String(value.value_unit ?? attribute.unit ?? '').trim();
|
|
164
|
-
return unit ? `${numberValue} ${unit}` : String(numberValue);
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
if (dataType === 'boolean') {
|
|
168
|
-
if (value.value_boolean === null || value.value_boolean === undefined) {
|
|
169
|
-
return '-';
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
return value.value_boolean
|
|
173
|
-
? t('resource.booleans.true')
|
|
174
|
-
: t('resource.booleans.false');
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
return String(value.value_text ?? '-');
|
|
178
|
-
};
|
|
179
|
-
|
|
180
|
-
const renderJsonBlock = (value: unknown) => {
|
|
181
|
-
if (!value || (typeof value === 'object' && Object.keys(value as object).length === 0)) {
|
|
182
|
-
return null;
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
return (
|
|
186
|
-
<pre className="overflow-x-auto rounded-md border bg-muted/30 p-3 text-xs">
|
|
187
|
-
{JSON.stringify(value, null, 2)}
|
|
188
|
-
</pre>
|
|
189
|
-
);
|
|
190
|
-
};
|
|
191
|
-
|
|
192
|
-
const specSnapshotBlock = renderJsonBlock(product?.spec_snapshot_json);
|
|
193
|
-
const comparisonSnapshotBlock = renderJsonBlock(product?.comparison_snapshot_json);
|
|
194
|
-
|
|
195
|
-
return (
|
|
196
|
-
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
197
|
-
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-4xl">
|
|
198
|
-
<DialogHeader>
|
|
199
|
-
<DialogTitle>
|
|
200
|
-
{String(product?.name ?? (isPt ? 'Visualizar produto' : 'View product'))}
|
|
201
|
-
</DialogTitle>
|
|
202
|
-
<DialogDescription>
|
|
203
|
-
{isPt
|
|
204
|
-
? 'Dados básicos, atributos técnicos por grupo, imagens e snapshots secundários.'
|
|
205
|
-
: 'Basic data, technical attributes by group, images, and secondary snapshots.'}
|
|
206
|
-
</DialogDescription>
|
|
207
|
-
</DialogHeader>
|
|
208
|
-
|
|
209
|
-
{isLoadingProduct || isLoadingAttributes ? (
|
|
210
|
-
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
|
211
|
-
<Eye className="size-4" />
|
|
212
|
-
{isPt ? 'Carregando visualização do produto...' : 'Loading product preview...'}
|
|
213
|
-
</div>
|
|
214
|
-
) : !product ? (
|
|
215
|
-
<p className="text-sm text-muted-foreground">
|
|
216
|
-
{isPt ? 'Produto não encontrado.' : 'Product not found.'}
|
|
217
|
-
</p>
|
|
218
|
-
) : (
|
|
219
|
-
<div className="space-y-6">
|
|
220
|
-
<Card>
|
|
221
|
-
<CardHeader>
|
|
222
|
-
<CardTitle>{isPt ? 'Dados básicos do produto' : 'Product basics'}</CardTitle>
|
|
223
|
-
</CardHeader>
|
|
224
|
-
<CardContent className="grid gap-4 sm:grid-cols-2">
|
|
225
|
-
<div>
|
|
226
|
-
<div className="text-xs text-muted-foreground">
|
|
227
|
-
{t('resource.fields.brandId')}
|
|
228
|
-
</div>
|
|
229
|
-
<div className="font-medium">
|
|
230
|
-
{String(product.brand_name ?? product.brand_id ?? '-')}
|
|
231
|
-
</div>
|
|
232
|
-
</div>
|
|
233
|
-
<div>
|
|
234
|
-
<div className="text-xs text-muted-foreground">
|
|
235
|
-
{t('resource.fields.catalogCategoryId')}
|
|
236
|
-
</div>
|
|
237
|
-
<div className="font-medium">
|
|
238
|
-
{String(product.category_name ?? product.catalog_category_id ?? '-')}
|
|
239
|
-
</div>
|
|
240
|
-
</div>
|
|
241
|
-
<div>
|
|
242
|
-
<div className="text-xs text-muted-foreground">{t('resource.fields.status')}</div>
|
|
243
|
-
<div className="font-medium">
|
|
244
|
-
{String(product.status ?? '-')}
|
|
245
|
-
</div>
|
|
246
|
-
</div>
|
|
247
|
-
<div>
|
|
248
|
-
<div className="text-xs text-muted-foreground">{t('resource.fields.isActive')}</div>
|
|
249
|
-
<div className="font-medium">
|
|
250
|
-
{product.is_active === true
|
|
251
|
-
? t('resource.booleans.true')
|
|
252
|
-
: t('resource.booleans.false')}
|
|
253
|
-
</div>
|
|
254
|
-
</div>
|
|
255
|
-
<div>
|
|
256
|
-
<div className="text-xs text-muted-foreground">{t('resource.fields.sku')}</div>
|
|
257
|
-
<div className="font-medium">{String(product.sku ?? '-')}</div>
|
|
258
|
-
</div>
|
|
259
|
-
<div>
|
|
260
|
-
<div className="text-xs text-muted-foreground">GTIN</div>
|
|
261
|
-
<div className="font-medium">{String(product.gtin ?? '-')}</div>
|
|
262
|
-
</div>
|
|
263
|
-
</CardContent>
|
|
264
|
-
</Card>
|
|
265
|
-
|
|
266
|
-
<Card>
|
|
267
|
-
<CardHeader>
|
|
268
|
-
<CardTitle>{isPt ? 'Atributos técnicos' : 'Technical attributes'}</CardTitle>
|
|
269
|
-
<CardDescription>
|
|
270
|
-
{isPt
|
|
271
|
-
? 'Organizados pelos grupos configurados na categoria do produto.'
|
|
272
|
-
: 'Organized by the groups configured for the product category.'}
|
|
273
|
-
</CardDescription>
|
|
274
|
-
</CardHeader>
|
|
275
|
-
<CardContent className="space-y-6">
|
|
276
|
-
{!(attributes?.groups?.length) ? (
|
|
277
|
-
<p className="text-sm text-muted-foreground">
|
|
278
|
-
{isPt
|
|
279
|
-
? 'Nenhum atributo técnico configurado para este produto.'
|
|
280
|
-
: 'No technical attributes configured for this product.'}
|
|
281
|
-
</p>
|
|
282
|
-
) : (
|
|
283
|
-
attributes.groups.map((group) => (
|
|
284
|
-
<div key={`${group.slug}-${group.id ?? 'general'}`} className="space-y-3">
|
|
285
|
-
<div className="text-sm font-semibold">{group.name}</div>
|
|
286
|
-
<div className="grid gap-3 sm:grid-cols-2">
|
|
287
|
-
{group.attributes.map((attribute) => {
|
|
288
|
-
const relation = (attribute.category_attribute ?? {}) as CatalogRecord;
|
|
289
|
-
const badges = [
|
|
290
|
-
relation.is_highlight
|
|
291
|
-
? isPt
|
|
292
|
-
? 'Destaque'
|
|
293
|
-
: 'Highlight'
|
|
294
|
-
: null,
|
|
295
|
-
relation.is_filter_visible
|
|
296
|
-
? isPt
|
|
297
|
-
? 'Filtro'
|
|
298
|
-
: 'Filter'
|
|
299
|
-
: null,
|
|
300
|
-
relation.is_comparison_visible
|
|
301
|
-
? isPt
|
|
302
|
-
? 'Comparação'
|
|
303
|
-
: 'Comparison'
|
|
304
|
-
: null,
|
|
305
|
-
].filter((item): item is string => Boolean(item));
|
|
306
|
-
|
|
307
|
-
return (
|
|
308
|
-
<div key={String(attribute.id)} className="rounded-lg border p-3">
|
|
309
|
-
<div className="flex items-start justify-between gap-3">
|
|
310
|
-
<div className="space-y-1">
|
|
311
|
-
<div className="font-medium">
|
|
312
|
-
{String(attribute.name ?? attribute.label ?? attribute.slug)}
|
|
313
|
-
</div>
|
|
314
|
-
<div className="text-sm text-muted-foreground">
|
|
315
|
-
{formatAttributeValue(attribute)}
|
|
316
|
-
</div>
|
|
317
|
-
</div>
|
|
318
|
-
{badges.length ? (
|
|
319
|
-
<div className="flex flex-wrap justify-end gap-2">
|
|
320
|
-
{badges.map((badge) => (
|
|
321
|
-
<Badge key={`${attribute.id}-${badge}`} variant="secondary">
|
|
322
|
-
{badge}
|
|
323
|
-
</Badge>
|
|
324
|
-
))}
|
|
325
|
-
</div>
|
|
326
|
-
) : null}
|
|
327
|
-
</div>
|
|
328
|
-
</div>
|
|
329
|
-
);
|
|
330
|
-
})}
|
|
331
|
-
</div>
|
|
332
|
-
</div>
|
|
333
|
-
))
|
|
334
|
-
)}
|
|
335
|
-
</CardContent>
|
|
336
|
-
</Card>
|
|
337
|
-
|
|
338
|
-
{Array.isArray(product.catalog_product_image) &&
|
|
339
|
-
product.catalog_product_image.length > 0 ? (
|
|
340
|
-
<Card>
|
|
341
|
-
<CardHeader>
|
|
342
|
-
<CardTitle>{isPt ? 'Imagens do produto' : 'Product images'}</CardTitle>
|
|
343
|
-
</CardHeader>
|
|
344
|
-
<CardContent className="grid gap-3 sm:grid-cols-2">
|
|
345
|
-
{product.catalog_product_image.map((image) => (
|
|
346
|
-
<div
|
|
347
|
-
key={String(image.id)}
|
|
348
|
-
className="rounded-lg border p-3 text-sm"
|
|
349
|
-
>
|
|
350
|
-
<div className="font-medium">
|
|
351
|
-
#{String(image.id)} · {String(image.role ?? 'gallery')}
|
|
352
|
-
</div>
|
|
353
|
-
<div className="text-muted-foreground">
|
|
354
|
-
File #{String(image.file_id ?? '-')}
|
|
355
|
-
</div>
|
|
356
|
-
</div>
|
|
357
|
-
))}
|
|
358
|
-
</CardContent>
|
|
359
|
-
</Card>
|
|
360
|
-
) : null}
|
|
361
|
-
|
|
362
|
-
<Card>
|
|
363
|
-
<CardHeader>
|
|
364
|
-
<CardTitle>{isPt ? 'Snapshots secundários' : 'Secondary snapshots'}</CardTitle>
|
|
365
|
-
<CardDescription>
|
|
366
|
-
{isPt
|
|
367
|
-
? 'Mantidos como apoio para cache e leituras legadas.'
|
|
368
|
-
: 'Kept as support for cache and legacy reads.'}
|
|
369
|
-
</CardDescription>
|
|
370
|
-
</CardHeader>
|
|
371
|
-
<CardContent className="space-y-4">
|
|
372
|
-
{specSnapshotBlock}
|
|
373
|
-
{comparisonSnapshotBlock}
|
|
374
|
-
{!specSnapshotBlock && !comparisonSnapshotBlock ? (
|
|
375
|
-
<p className="text-sm text-muted-foreground">
|
|
376
|
-
{isPt
|
|
377
|
-
? 'Nenhum snapshot secundário disponível.'
|
|
378
|
-
: 'No secondary snapshots available.'}
|
|
379
|
-
</p>
|
|
380
|
-
) : null}
|
|
381
|
-
</CardContent>
|
|
382
|
-
</Card>
|
|
383
|
-
</div>
|
|
384
|
-
)}
|
|
385
|
-
</DialogContent>
|
|
386
|
-
</Dialog>
|
|
387
|
-
);
|
|
388
|
-
}
|
|
87
|
+
.replace(/\b\w/g, (char) => char.toUpperCase());
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function CatalogProductPreviewDialog({
|
|
91
|
+
open,
|
|
92
|
+
onOpenChange,
|
|
93
|
+
productId,
|
|
94
|
+
}: {
|
|
95
|
+
open: boolean;
|
|
96
|
+
onOpenChange: (open: boolean) => void;
|
|
97
|
+
productId: number | null;
|
|
98
|
+
}) {
|
|
99
|
+
const { request, currentLocaleCode } = useApp();
|
|
100
|
+
const t = useTranslations('catalog');
|
|
101
|
+
const isPt = currentLocaleCode?.startsWith('pt');
|
|
102
|
+
const { data: product, isLoading: isLoadingProduct } = useQuery<CatalogRecord | null>({
|
|
103
|
+
queryKey: ['catalog-product-preview', productId],
|
|
104
|
+
queryFn: async () => {
|
|
105
|
+
if (!productId) {
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const response = await request({
|
|
110
|
+
url: `/catalog/products/${productId}`,
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
return (response.data ?? null) as CatalogRecord | null;
|
|
114
|
+
},
|
|
115
|
+
enabled: open && Boolean(productId),
|
|
116
|
+
});
|
|
117
|
+
const { data: attributes, isLoading: isLoadingAttributes } =
|
|
118
|
+
useQuery<ProductAttributePayload | null>({
|
|
119
|
+
queryKey: ['catalog-product-preview-attributes', productId],
|
|
120
|
+
queryFn: async () => {
|
|
121
|
+
if (!productId) {
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const response = await request({
|
|
126
|
+
url: `/catalog/products/${productId}/attributes`,
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
return (response.data ?? null) as ProductAttributePayload | null;
|
|
130
|
+
},
|
|
131
|
+
enabled: open && Boolean(productId),
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
const formatAttributeValue = (attribute: CatalogRecord & {
|
|
135
|
+
options?: CatalogRecord[];
|
|
136
|
+
value?: CatalogRecord | null;
|
|
137
|
+
}) => {
|
|
138
|
+
const value = (attribute.value ?? null) as CatalogRecord | null;
|
|
139
|
+
|
|
140
|
+
if (!value) {
|
|
141
|
+
return '-';
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const dataType = String(attribute.data_type ?? 'text');
|
|
145
|
+
|
|
146
|
+
if (dataType === 'option') {
|
|
147
|
+
const option = (attribute.options ?? []).find(
|
|
148
|
+
(item) => Number(item.id ?? 0) === Number(value.attribute_option_id ?? 0),
|
|
149
|
+
);
|
|
150
|
+
return String(option?.label ?? option?.option_value ?? value.value_text ?? '-');
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (dataType === 'number') {
|
|
154
|
+
const numberValue =
|
|
155
|
+
value.value_number === null || value.value_number === undefined
|
|
156
|
+
? null
|
|
157
|
+
: Number(value.value_number);
|
|
158
|
+
|
|
159
|
+
if (numberValue === null) {
|
|
160
|
+
return '-';
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const unit = String(value.value_unit ?? attribute.unit ?? '').trim();
|
|
164
|
+
return unit ? `${numberValue} ${unit}` : String(numberValue);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (dataType === 'boolean') {
|
|
168
|
+
if (value.value_boolean === null || value.value_boolean === undefined) {
|
|
169
|
+
return '-';
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return value.value_boolean
|
|
173
|
+
? t('resource.booleans.true')
|
|
174
|
+
: t('resource.booleans.false');
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return String(value.value_text ?? '-');
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
const renderJsonBlock = (value: unknown) => {
|
|
181
|
+
if (!value || (typeof value === 'object' && Object.keys(value as object).length === 0)) {
|
|
182
|
+
return null;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return (
|
|
186
|
+
<pre className="overflow-x-auto rounded-md border bg-muted/30 p-3 text-xs">
|
|
187
|
+
{JSON.stringify(value, null, 2)}
|
|
188
|
+
</pre>
|
|
189
|
+
);
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
const specSnapshotBlock = renderJsonBlock(product?.spec_snapshot_json);
|
|
193
|
+
const comparisonSnapshotBlock = renderJsonBlock(product?.comparison_snapshot_json);
|
|
194
|
+
|
|
195
|
+
return (
|
|
196
|
+
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
197
|
+
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-4xl">
|
|
198
|
+
<DialogHeader>
|
|
199
|
+
<DialogTitle>
|
|
200
|
+
{String(product?.name ?? (isPt ? 'Visualizar produto' : 'View product'))}
|
|
201
|
+
</DialogTitle>
|
|
202
|
+
<DialogDescription>
|
|
203
|
+
{isPt
|
|
204
|
+
? 'Dados básicos, atributos técnicos por grupo, imagens e snapshots secundários.'
|
|
205
|
+
: 'Basic data, technical attributes by group, images, and secondary snapshots.'}
|
|
206
|
+
</DialogDescription>
|
|
207
|
+
</DialogHeader>
|
|
208
|
+
|
|
209
|
+
{isLoadingProduct || isLoadingAttributes ? (
|
|
210
|
+
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
|
211
|
+
<Eye className="size-4" />
|
|
212
|
+
{isPt ? 'Carregando visualização do produto...' : 'Loading product preview...'}
|
|
213
|
+
</div>
|
|
214
|
+
) : !product ? (
|
|
215
|
+
<p className="text-sm text-muted-foreground">
|
|
216
|
+
{isPt ? 'Produto não encontrado.' : 'Product not found.'}
|
|
217
|
+
</p>
|
|
218
|
+
) : (
|
|
219
|
+
<div className="space-y-6">
|
|
220
|
+
<Card>
|
|
221
|
+
<CardHeader>
|
|
222
|
+
<CardTitle>{isPt ? 'Dados básicos do produto' : 'Product basics'}</CardTitle>
|
|
223
|
+
</CardHeader>
|
|
224
|
+
<CardContent className="grid gap-4 sm:grid-cols-2">
|
|
225
|
+
<div>
|
|
226
|
+
<div className="text-xs text-muted-foreground">
|
|
227
|
+
{t('resource.fields.brandId')}
|
|
228
|
+
</div>
|
|
229
|
+
<div className="font-medium">
|
|
230
|
+
{String(product.brand_name ?? product.brand_id ?? '-')}
|
|
231
|
+
</div>
|
|
232
|
+
</div>
|
|
233
|
+
<div>
|
|
234
|
+
<div className="text-xs text-muted-foreground">
|
|
235
|
+
{t('resource.fields.catalogCategoryId')}
|
|
236
|
+
</div>
|
|
237
|
+
<div className="font-medium">
|
|
238
|
+
{String(product.category_name ?? product.catalog_category_id ?? '-')}
|
|
239
|
+
</div>
|
|
240
|
+
</div>
|
|
241
|
+
<div>
|
|
242
|
+
<div className="text-xs text-muted-foreground">{t('resource.fields.status')}</div>
|
|
243
|
+
<div className="font-medium">
|
|
244
|
+
{String(product.status ?? '-')}
|
|
245
|
+
</div>
|
|
246
|
+
</div>
|
|
247
|
+
<div>
|
|
248
|
+
<div className="text-xs text-muted-foreground">{t('resource.fields.isActive')}</div>
|
|
249
|
+
<div className="font-medium">
|
|
250
|
+
{product.is_active === true
|
|
251
|
+
? t('resource.booleans.true')
|
|
252
|
+
: t('resource.booleans.false')}
|
|
253
|
+
</div>
|
|
254
|
+
</div>
|
|
255
|
+
<div>
|
|
256
|
+
<div className="text-xs text-muted-foreground">{t('resource.fields.sku')}</div>
|
|
257
|
+
<div className="font-medium">{String(product.sku ?? '-')}</div>
|
|
258
|
+
</div>
|
|
259
|
+
<div>
|
|
260
|
+
<div className="text-xs text-muted-foreground">GTIN</div>
|
|
261
|
+
<div className="font-medium">{String(product.gtin ?? '-')}</div>
|
|
262
|
+
</div>
|
|
263
|
+
</CardContent>
|
|
264
|
+
</Card>
|
|
265
|
+
|
|
266
|
+
<Card>
|
|
267
|
+
<CardHeader>
|
|
268
|
+
<CardTitle>{isPt ? 'Atributos técnicos' : 'Technical attributes'}</CardTitle>
|
|
269
|
+
<CardDescription>
|
|
270
|
+
{isPt
|
|
271
|
+
? 'Organizados pelos grupos configurados na categoria do produto.'
|
|
272
|
+
: 'Organized by the groups configured for the product category.'}
|
|
273
|
+
</CardDescription>
|
|
274
|
+
</CardHeader>
|
|
275
|
+
<CardContent className="space-y-6">
|
|
276
|
+
{!(attributes?.groups?.length) ? (
|
|
277
|
+
<p className="text-sm text-muted-foreground">
|
|
278
|
+
{isPt
|
|
279
|
+
? 'Nenhum atributo técnico configurado para este produto.'
|
|
280
|
+
: 'No technical attributes configured for this product.'}
|
|
281
|
+
</p>
|
|
282
|
+
) : (
|
|
283
|
+
attributes.groups.map((group) => (
|
|
284
|
+
<div key={`${group.slug}-${group.id ?? 'general'}`} className="space-y-3">
|
|
285
|
+
<div className="text-sm font-semibold">{group.name}</div>
|
|
286
|
+
<div className="grid gap-3 sm:grid-cols-2">
|
|
287
|
+
{group.attributes.map((attribute) => {
|
|
288
|
+
const relation = (attribute.category_attribute ?? {}) as CatalogRecord;
|
|
289
|
+
const badges = [
|
|
290
|
+
relation.is_highlight
|
|
291
|
+
? isPt
|
|
292
|
+
? 'Destaque'
|
|
293
|
+
: 'Highlight'
|
|
294
|
+
: null,
|
|
295
|
+
relation.is_filter_visible
|
|
296
|
+
? isPt
|
|
297
|
+
? 'Filtro'
|
|
298
|
+
: 'Filter'
|
|
299
|
+
: null,
|
|
300
|
+
relation.is_comparison_visible
|
|
301
|
+
? isPt
|
|
302
|
+
? 'Comparação'
|
|
303
|
+
: 'Comparison'
|
|
304
|
+
: null,
|
|
305
|
+
].filter((item): item is string => Boolean(item));
|
|
306
|
+
|
|
307
|
+
return (
|
|
308
|
+
<div key={String(attribute.id)} className="rounded-lg border p-3">
|
|
309
|
+
<div className="flex items-start justify-between gap-3">
|
|
310
|
+
<div className="space-y-1">
|
|
311
|
+
<div className="font-medium">
|
|
312
|
+
{String(attribute.name ?? attribute.label ?? attribute.slug)}
|
|
313
|
+
</div>
|
|
314
|
+
<div className="text-sm text-muted-foreground">
|
|
315
|
+
{formatAttributeValue(attribute)}
|
|
316
|
+
</div>
|
|
317
|
+
</div>
|
|
318
|
+
{badges.length ? (
|
|
319
|
+
<div className="flex flex-wrap justify-end gap-2">
|
|
320
|
+
{badges.map((badge) => (
|
|
321
|
+
<Badge key={`${attribute.id}-${badge}`} variant="secondary">
|
|
322
|
+
{badge}
|
|
323
|
+
</Badge>
|
|
324
|
+
))}
|
|
325
|
+
</div>
|
|
326
|
+
) : null}
|
|
327
|
+
</div>
|
|
328
|
+
</div>
|
|
329
|
+
);
|
|
330
|
+
})}
|
|
331
|
+
</div>
|
|
332
|
+
</div>
|
|
333
|
+
))
|
|
334
|
+
)}
|
|
335
|
+
</CardContent>
|
|
336
|
+
</Card>
|
|
337
|
+
|
|
338
|
+
{Array.isArray(product.catalog_product_image) &&
|
|
339
|
+
product.catalog_product_image.length > 0 ? (
|
|
340
|
+
<Card>
|
|
341
|
+
<CardHeader>
|
|
342
|
+
<CardTitle>{isPt ? 'Imagens do produto' : 'Product images'}</CardTitle>
|
|
343
|
+
</CardHeader>
|
|
344
|
+
<CardContent className="grid gap-3 sm:grid-cols-2">
|
|
345
|
+
{product.catalog_product_image.map((image) => (
|
|
346
|
+
<div
|
|
347
|
+
key={String(image.id)}
|
|
348
|
+
className="rounded-lg border p-3 text-sm"
|
|
349
|
+
>
|
|
350
|
+
<div className="font-medium">
|
|
351
|
+
#{String(image.id)} · {String(image.role ?? 'gallery')}
|
|
352
|
+
</div>
|
|
353
|
+
<div className="text-muted-foreground">
|
|
354
|
+
File #{String(image.file_id ?? '-')}
|
|
355
|
+
</div>
|
|
356
|
+
</div>
|
|
357
|
+
))}
|
|
358
|
+
</CardContent>
|
|
359
|
+
</Card>
|
|
360
|
+
) : null}
|
|
361
|
+
|
|
362
|
+
<Card>
|
|
363
|
+
<CardHeader>
|
|
364
|
+
<CardTitle>{isPt ? 'Snapshots secundários' : 'Secondary snapshots'}</CardTitle>
|
|
365
|
+
<CardDescription>
|
|
366
|
+
{isPt
|
|
367
|
+
? 'Mantidos como apoio para cache e leituras legadas.'
|
|
368
|
+
: 'Kept as support for cache and legacy reads.'}
|
|
369
|
+
</CardDescription>
|
|
370
|
+
</CardHeader>
|
|
371
|
+
<CardContent className="space-y-4">
|
|
372
|
+
{specSnapshotBlock}
|
|
373
|
+
{comparisonSnapshotBlock}
|
|
374
|
+
{!specSnapshotBlock && !comparisonSnapshotBlock ? (
|
|
375
|
+
<p className="text-sm text-muted-foreground">
|
|
376
|
+
{isPt
|
|
377
|
+
? 'Nenhum snapshot secundário disponível.'
|
|
378
|
+
: 'No secondary snapshots available.'}
|
|
379
|
+
</p>
|
|
380
|
+
) : null}
|
|
381
|
+
</CardContent>
|
|
382
|
+
</Card>
|
|
383
|
+
</div>
|
|
384
|
+
)}
|
|
385
|
+
</DialogContent>
|
|
386
|
+
</Dialog>
|
|
387
|
+
);
|
|
388
|
+
}
|
|
389
389
|
|
|
390
390
|
export default function CatalogResourcePage() {
|
|
391
391
|
const params = useParams<{ resource: string }>();
|
|
@@ -398,10 +398,10 @@ export default function CatalogResourcePage() {
|
|
|
398
398
|
const [filterValue, setFilterValue] = useState('all');
|
|
399
399
|
const [page, setPage] = useState(1);
|
|
400
400
|
const [pageSize, setPageSize] = useState(12);
|
|
401
|
-
const [sheetOpen, setSheetOpen] = useState(false);
|
|
402
|
-
const [deleteId, setDeleteId] = useState<number | null>(null);
|
|
403
|
-
const [editingRecordId, setEditingRecordId] = useState<number | null>(null);
|
|
404
|
-
const [viewingRecordId, setViewingRecordId] = useState<number | null>(null);
|
|
401
|
+
const [sheetOpen, setSheetOpen] = useState(false);
|
|
402
|
+
const [deleteId, setDeleteId] = useState<number | null>(null);
|
|
403
|
+
const [editingRecordId, setEditingRecordId] = useState<number | null>(null);
|
|
404
|
+
const [viewingRecordId, setViewingRecordId] = useState<number | null>(null);
|
|
405
405
|
const resourceTitle = config
|
|
406
406
|
? t(`resources.${config.translationKey}.title`)
|
|
407
407
|
: t('unsupportedResource');
|
|
@@ -682,14 +682,14 @@ export default function CatalogResourcePage() {
|
|
|
682
682
|
setSheetOpen(true);
|
|
683
683
|
};
|
|
684
684
|
|
|
685
|
-
const openEdit = (record: CatalogRecord) => {
|
|
686
|
-
setEditingRecordId(Number(record.id));
|
|
687
|
-
setSheetOpen(true);
|
|
688
|
-
};
|
|
689
|
-
|
|
690
|
-
const openView = (record: CatalogRecord) => {
|
|
691
|
-
setViewingRecordId(Number(record.id));
|
|
692
|
-
};
|
|
685
|
+
const openEdit = (record: CatalogRecord) => {
|
|
686
|
+
setEditingRecordId(Number(record.id));
|
|
687
|
+
setSheetOpen(true);
|
|
688
|
+
};
|
|
689
|
+
|
|
690
|
+
const openView = (record: CatalogRecord) => {
|
|
691
|
+
setViewingRecordId(Number(record.id));
|
|
692
|
+
};
|
|
693
693
|
|
|
694
694
|
const handleDelete = async () => {
|
|
695
695
|
if (!deleteId) {
|
|
@@ -853,20 +853,20 @@ export default function CatalogResourcePage() {
|
|
|
853
853
|
</TableCell>
|
|
854
854
|
))}
|
|
855
855
|
<TableCell className="text-right">
|
|
856
|
-
<div className="flex justify-end gap-2">
|
|
857
|
-
{resource === 'products' ? (
|
|
858
|
-
<Button
|
|
859
|
-
variant="outline"
|
|
860
|
-
size="icon"
|
|
861
|
-
onClick={() => openView(record)}
|
|
862
|
-
>
|
|
863
|
-
<Eye className="size-4" />
|
|
864
|
-
</Button>
|
|
865
|
-
) : null}
|
|
866
|
-
<Button
|
|
867
|
-
variant="outline"
|
|
868
|
-
size="icon"
|
|
869
|
-
onClick={() => openEdit(record)}
|
|
856
|
+
<div className="flex justify-end gap-2">
|
|
857
|
+
{resource === 'products' ? (
|
|
858
|
+
<Button
|
|
859
|
+
variant="outline"
|
|
860
|
+
size="icon"
|
|
861
|
+
onClick={() => openView(record)}
|
|
862
|
+
>
|
|
863
|
+
<Eye className="size-4" />
|
|
864
|
+
</Button>
|
|
865
|
+
) : null}
|
|
866
|
+
<Button
|
|
867
|
+
variant="outline"
|
|
868
|
+
size="icon"
|
|
869
|
+
onClick={() => openEdit(record)}
|
|
870
870
|
>
|
|
871
871
|
<Pencil className="size-4" />
|
|
872
872
|
</Button>
|
|
@@ -909,21 +909,21 @@ export default function CatalogResourcePage() {
|
|
|
909
909
|
description={getCardDescription(record)}
|
|
910
910
|
badges={getBadges(record)}
|
|
911
911
|
metadata={getCardMetadata(record)}
|
|
912
|
-
actions={[
|
|
913
|
-
...(resource === 'products'
|
|
914
|
-
? [
|
|
915
|
-
{
|
|
916
|
-
label: t('openResource'),
|
|
917
|
-
onClick: () => openView(record),
|
|
918
|
-
variant: 'outline' as const,
|
|
919
|
-
icon: <Eye className="size-4" />,
|
|
920
|
-
},
|
|
921
|
-
]
|
|
922
|
-
: []),
|
|
923
|
-
{
|
|
924
|
-
label: t('edit'),
|
|
925
|
-
onClick: () => openEdit(record),
|
|
926
|
-
variant: 'outline',
|
|
912
|
+
actions={[
|
|
913
|
+
...(resource === 'products'
|
|
914
|
+
? [
|
|
915
|
+
{
|
|
916
|
+
label: t('openResource'),
|
|
917
|
+
onClick: () => openView(record),
|
|
918
|
+
variant: 'outline' as const,
|
|
919
|
+
icon: <Eye className="size-4" />,
|
|
920
|
+
},
|
|
921
|
+
]
|
|
922
|
+
: []),
|
|
923
|
+
{
|
|
924
|
+
label: t('edit'),
|
|
925
|
+
onClick: () => openEdit(record),
|
|
926
|
+
variant: 'outline',
|
|
927
927
|
icon: <Pencil className="size-4" />,
|
|
928
928
|
},
|
|
929
929
|
{
|
|
@@ -950,7 +950,7 @@ export default function CatalogResourcePage() {
|
|
|
950
950
|
</div>
|
|
951
951
|
)}
|
|
952
952
|
</div>
|
|
953
|
-
<CatalogResourceFormSheet
|
|
953
|
+
<CatalogResourceFormSheet
|
|
954
954
|
open={sheetOpen}
|
|
955
955
|
onOpenChange={(open) => {
|
|
956
956
|
setSheetOpen(open);
|
|
@@ -967,21 +967,21 @@ export default function CatalogResourcePage() {
|
|
|
967
967
|
setEditingRecordId(null);
|
|
968
968
|
await refetch();
|
|
969
969
|
}}
|
|
970
|
-
/>
|
|
971
|
-
|
|
972
|
-
{resource === 'products' ? (
|
|
973
|
-
<CatalogProductPreviewDialog
|
|
974
|
-
open={viewingRecordId !== null}
|
|
975
|
-
onOpenChange={(open) => {
|
|
976
|
-
if (!open) {
|
|
977
|
-
setViewingRecordId(null);
|
|
978
|
-
}
|
|
979
|
-
}}
|
|
980
|
-
productId={viewingRecordId}
|
|
981
|
-
/>
|
|
982
|
-
) : null}
|
|
983
|
-
|
|
984
|
-
<AlertDialog
|
|
970
|
+
/>
|
|
971
|
+
|
|
972
|
+
{resource === 'products' ? (
|
|
973
|
+
<CatalogProductPreviewDialog
|
|
974
|
+
open={viewingRecordId !== null}
|
|
975
|
+
onOpenChange={(open) => {
|
|
976
|
+
if (!open) {
|
|
977
|
+
setViewingRecordId(null);
|
|
978
|
+
}
|
|
979
|
+
}}
|
|
980
|
+
productId={viewingRecordId}
|
|
981
|
+
/>
|
|
982
|
+
) : null}
|
|
983
|
+
|
|
984
|
+
<AlertDialog
|
|
985
985
|
open={deleteId !== null}
|
|
986
986
|
onOpenChange={(open) => !open && setDeleteId(null)}
|
|
987
987
|
>
|