@hed-hog/catalog 0.0.294 → 0.0.295

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 (55) hide show
  1. package/README.md +409 -409
  2. package/dist/catalog.service.d.ts.map +1 -1
  3. package/dist/catalog.service.js +14 -6
  4. package/dist/catalog.service.js.map +1 -1
  5. package/hedhog/data/catalog_attribute.yaml +202 -202
  6. package/hedhog/data/catalog_attribute_option.yaml +109 -109
  7. package/hedhog/data/catalog_category.yaml +47 -47
  8. package/hedhog/data/catalog_category_attribute.yaml +209 -209
  9. package/hedhog/data/menu.yaml +133 -133
  10. package/hedhog/data/role.yaml +7 -7
  11. package/hedhog/data/route.yaml +72 -72
  12. package/hedhog/frontend/app/[resource]/page.tsx.ejs +391 -391
  13. package/hedhog/frontend/app/_components/catalog-ai-form-assist-dialog.tsx.ejs +340 -340
  14. package/hedhog/frontend/app/_components/catalog-resource-form-sheet.tsx.ejs +907 -907
  15. package/hedhog/frontend/app/_lib/catalog-resources.tsx.ejs +929 -929
  16. package/hedhog/frontend/app/dashboard/page.tsx.ejs +14 -83
  17. package/hedhog/frontend/messages/en.json +389 -389
  18. package/hedhog/frontend/messages/pt.json +389 -389
  19. package/hedhog/table/catalog_affiliate_program.yaml +41 -41
  20. package/hedhog/table/catalog_attribute.yaml +67 -67
  21. package/hedhog/table/catalog_attribute_group.yaml +18 -18
  22. package/hedhog/table/catalog_attribute_option.yaml +40 -40
  23. package/hedhog/table/catalog_brand.yaml +34 -34
  24. package/hedhog/table/catalog_category.yaml +40 -40
  25. package/hedhog/table/catalog_category_attribute.yaml +37 -37
  26. package/hedhog/table/catalog_click_event.yaml +50 -50
  27. package/hedhog/table/catalog_comparison.yaml +19 -19
  28. package/hedhog/table/catalog_comparison_highlight.yaml +39 -39
  29. package/hedhog/table/catalog_comparison_item.yaml +30 -30
  30. package/hedhog/table/catalog_content_relation.yaml +42 -42
  31. package/hedhog/table/catalog_import_run.yaml +33 -33
  32. package/hedhog/table/catalog_import_source.yaml +24 -24
  33. package/hedhog/table/catalog_merchant.yaml +29 -29
  34. package/hedhog/table/catalog_offer.yaml +83 -83
  35. package/hedhog/table/catalog_price_history.yaml +34 -34
  36. package/hedhog/table/catalog_product.yaml +30 -30
  37. package/hedhog/table/catalog_product_attribute_value.yaml +44 -44
  38. package/hedhog/table/catalog_product_category.yaml +13 -13
  39. package/hedhog/table/catalog_product_image.yaml +34 -34
  40. package/hedhog/table/catalog_product_score.yaml +38 -38
  41. package/hedhog/table/catalog_product_site.yaml +47 -47
  42. package/hedhog/table/catalog_product_tag.yaml +19 -19
  43. package/hedhog/table/catalog_score_criterion.yaml +42 -42
  44. package/hedhog/table/catalog_seo_page_rule.yaml +10 -10
  45. package/hedhog/table/catalog_similarity_rule.yaml +33 -33
  46. package/hedhog/table/catalog_site.yaml +21 -21
  47. package/hedhog/table/catalog_site_category.yaml +12 -12
  48. package/package.json +6 -6
  49. package/src/catalog-resource.config.ts +132 -132
  50. package/src/catalog.controller.ts +91 -91
  51. package/src/catalog.module.ts +16 -16
  52. package/src/catalog.service.ts +1585 -1573
  53. package/src/index.ts +1 -1
  54. package/src/language/en.json +4 -4
  55. package/src/language/pt.json +4 -4
@@ -1,8 +1,8 @@
1
1
  'use client';
2
2
 
3
- import { RichTextEditor } from '@/components/rich-text-editor';
4
- import { Badge } from '@/components/ui/badge';
5
- import { Button } from '@/components/ui/button';
3
+ import { RichTextEditor } from '@/components/rich-text-editor';
4
+ import { Badge } from '@/components/ui/badge';
5
+ import { Button } from '@/components/ui/button';
6
6
  import {
7
7
  Command,
8
8
  CommandEmpty,
@@ -52,54 +52,54 @@ import Image from 'next/image';
52
52
  import { useEffect, useMemo, useRef, useState } from 'react';
53
53
  import { useForm } from 'react-hook-form';
54
54
  import { toast } from 'sonner';
55
- import { z } from 'zod';
56
- import { CatalogAiFormAssistDialog } from './catalog-ai-form-assist-dialog';
57
- import {
58
- catalogResourceMap,
59
- getCatalogLocalizedText,
60
- getCatalogRecordLabel,
55
+ import { z } from 'zod';
56
+ import { CatalogAiFormAssistDialog } from './catalog-ai-form-assist-dialog';
57
+ import {
58
+ catalogResourceMap,
59
+ getCatalogLocalizedText,
60
+ getCatalogRecordLabel,
61
61
  type CatalogFormFieldDefinition,
62
62
  type CatalogResourceDefinition,
63
63
  } from '../_lib/catalog-resources';
64
64
 
65
- type CatalogRecord = Record<string, unknown>;
66
- type CatalogFormValues = Record<string, unknown>;
67
- type RelationOption = { id: number; label: string };
68
- type ProductAttributeMetadata = CatalogRecord & {
69
- id: number;
70
- data_type?: string;
71
- unit?: string | null;
72
- options?: CatalogRecord[];
73
- category_attribute?: CatalogRecord;
74
- };
75
- type ProductAttributeDraft = {
76
- attribute_id: number;
77
- data_type: string;
78
- attribute_option_id: number | null;
79
- value_text: string;
80
- value_number: number | null;
81
- value_boolean: boolean | null;
82
- value_unit: string;
83
- source_type: string;
84
- confidence_score: number | null;
85
- is_verified: boolean;
86
- };
87
- type CatalogAttributePayload = {
88
- category?: CatalogRecord;
89
- groups?: Array<{
90
- id: number | null;
91
- name: string;
92
- slug: string;
93
- attributes: Array<
94
- CatalogRecord & {
95
- options?: CatalogRecord[];
96
- value?: CatalogRecord | null;
97
- category_attribute?: CatalogRecord;
98
- }
99
- >;
100
- }>;
101
- };
102
- type FormSheetProps = {
65
+ type CatalogRecord = Record<string, unknown>;
66
+ type CatalogFormValues = Record<string, unknown>;
67
+ type RelationOption = { id: number; label: string };
68
+ type ProductAttributeMetadata = CatalogRecord & {
69
+ id: number;
70
+ data_type?: string;
71
+ unit?: string | null;
72
+ options?: CatalogRecord[];
73
+ category_attribute?: CatalogRecord;
74
+ };
75
+ type ProductAttributeDraft = {
76
+ attribute_id: number;
77
+ data_type: string;
78
+ attribute_option_id: number | null;
79
+ value_text: string;
80
+ value_number: number | null;
81
+ value_boolean: boolean | null;
82
+ value_unit: string;
83
+ source_type: string;
84
+ confidence_score: number | null;
85
+ is_verified: boolean;
86
+ };
87
+ type CatalogAttributePayload = {
88
+ category?: CatalogRecord;
89
+ groups?: Array<{
90
+ id: number | null;
91
+ name: string;
92
+ slug: string;
93
+ attributes: Array<
94
+ CatalogRecord & {
95
+ options?: CatalogRecord[];
96
+ value?: CatalogRecord | null;
97
+ category_attribute?: CatalogRecord;
98
+ }
99
+ >;
100
+ }>;
101
+ };
102
+ type FormSheetProps = {
103
103
  open: boolean;
104
104
  onOpenChange: (open: boolean) => void;
105
105
  resource: string;
@@ -246,10 +246,10 @@ function serializeInitialValue(
246
246
  return rawValue === null || rawValue === undefined ? '' : String(rawValue);
247
247
  }
248
248
 
249
- function buildDefaultValues(
250
- resourceConfig: CatalogResourceDefinition,
251
- payload: CatalogRecord
252
- ) {
249
+ function buildDefaultValues(
250
+ resourceConfig: CatalogResourceDefinition,
251
+ payload: CatalogRecord
252
+ ) {
253
253
  const defaults: CatalogFormValues = {};
254
254
 
255
255
  for (const section of resourceConfig.formSections) {
@@ -258,27 +258,27 @@ function buildDefaultValues(
258
258
  }
259
259
  }
260
260
 
261
- return defaults;
262
- }
263
-
264
- function buildCurrentFormPayload(
265
- resourceConfig: CatalogResourceDefinition,
266
- values: CatalogFormValues,
267
- ) {
268
- const payload: CatalogRecord = {};
269
-
270
- for (const section of resourceConfig.formSections) {
271
- for (const field of section.fields) {
272
- try {
273
- payload[field.key] = normalizeSubmitValue(field, values[field.key]);
274
- } catch {
275
- payload[field.key] = values[field.key] ?? null;
276
- }
277
- }
278
- }
279
-
280
- return payload;
281
- }
261
+ return defaults;
262
+ }
263
+
264
+ function buildCurrentFormPayload(
265
+ resourceConfig: CatalogResourceDefinition,
266
+ values: CatalogFormValues,
267
+ ) {
268
+ const payload: CatalogRecord = {};
269
+
270
+ for (const section of resourceConfig.formSections) {
271
+ for (const field of section.fields) {
272
+ try {
273
+ payload[field.key] = normalizeSubmitValue(field, values[field.key]);
274
+ } catch {
275
+ payload[field.key] = values[field.key] ?? null;
276
+ }
277
+ }
278
+ }
279
+
280
+ return payload;
281
+ }
282
282
 
283
283
  function normalizeSubmitValue(
284
284
  field: CatalogFormFieldDefinition,
@@ -323,227 +323,227 @@ function normalizeSubmitValue(
323
323
  return String(rawValue || '').trim() || null;
324
324
  }
325
325
 
326
- return rawValue;
327
- }
328
-
329
- function buildAttributeDrafts(payload?: CatalogAttributePayload | null) {
330
- const drafts: Record<number, ProductAttributeDraft> = {};
331
-
332
- for (const group of payload?.groups ?? []) {
333
- for (const attribute of group.attributes ?? []) {
334
- const attributeId = Number(attribute.id ?? 0);
335
- const value = (attribute.value ?? null) as CatalogRecord | null;
336
-
337
- if (!attributeId) {
338
- continue;
339
- }
340
-
341
- drafts[attributeId] = {
342
- attribute_id: attributeId,
343
- data_type: String(attribute.data_type ?? 'text'),
344
- attribute_option_id:
345
- value?.attribute_option_id === null ||
346
- value?.attribute_option_id === undefined
347
- ? null
348
- : Number(value.attribute_option_id),
349
- value_text: String(value?.value_text ?? ''),
350
- value_number:
351
- value?.value_number === null || value?.value_number === undefined
352
- ? null
353
- : Number(value.value_number),
354
- value_boolean:
355
- typeof value?.value_boolean === 'boolean' ? value.value_boolean : null,
356
- value_unit: String(value?.value_unit ?? attribute.unit ?? ''),
357
- source_type: String(value?.source_type ?? 'manual'),
358
- confidence_score:
359
- value?.confidence_score === null ||
360
- value?.confidence_score === undefined
361
- ? null
362
- : Number(value.confidence_score),
363
- is_verified: Boolean(value?.is_verified ?? false),
364
- };
365
- }
366
- }
367
-
368
- return drafts;
369
- }
370
-
371
- function createEmptyProductAttributeDraft(
372
- attributeId: number,
373
- dataType = 'text',
374
- unit = '',
375
- ): ProductAttributeDraft {
376
- return {
377
- attribute_id: attributeId,
378
- data_type: dataType,
379
- attribute_option_id: null,
380
- value_text: '',
381
- value_number: null,
382
- value_boolean: null,
383
- value_unit: unit,
384
- source_type: 'manual',
385
- confidence_score: null,
386
- is_verified: false,
387
- };
388
- }
389
-
390
- function normalizeProductAttributeDraft(
391
- attribute: ProductAttributeMetadata,
392
- draft?: ProductAttributeDraft,
393
- ) {
394
- const dataType = String(attribute.data_type ?? draft?.data_type ?? 'text');
395
- const normalized = {
396
- ...(draft ?? createEmptyProductAttributeDraft(Number(attribute.id), dataType, String(attribute.unit ?? ''))),
397
- attribute_id: Number(attribute.id),
398
- data_type: dataType,
399
- value_unit: String(draft?.value_unit ?? attribute.unit ?? ''),
400
- } satisfies ProductAttributeDraft;
401
-
402
- if (dataType === 'option') {
403
- return {
404
- ...normalized,
405
- value_text: '',
406
- value_number: null,
407
- value_boolean: null,
408
- value_unit: '',
409
- };
410
- }
411
-
412
- if (dataType === 'number') {
413
- return {
414
- ...normalized,
415
- attribute_option_id: null,
416
- value_text: '',
417
- value_boolean: null,
418
- };
419
- }
420
-
421
- if (dataType === 'boolean') {
422
- return {
423
- ...normalized,
424
- attribute_option_id: null,
425
- value_text: '',
426
- value_number: null,
427
- value_unit: '',
428
- };
429
- }
430
-
431
- return {
432
- ...normalized,
433
- attribute_option_id: null,
434
- value_number: null,
435
- value_boolean: null,
436
- value_unit: dataType === 'text' || dataType === 'long_text' ? '' : normalized.value_unit,
437
- };
438
- }
439
-
440
- function hasMeaningfulProductAttributeValue(
441
- attribute: ProductAttributeMetadata,
442
- draft: ProductAttributeDraft,
443
- ) {
444
- const dataType = String(attribute.data_type ?? draft.data_type ?? 'text');
445
-
446
- if (dataType === 'option') {
447
- return draft.attribute_option_id !== null && draft.attribute_option_id !== undefined;
448
- }
449
-
450
- if (dataType === 'number') {
451
- return draft.value_number !== null && draft.value_number !== undefined;
452
- }
453
-
454
- if (dataType === 'boolean') {
455
- return draft.value_boolean !== null && draft.value_boolean !== undefined;
456
- }
457
-
458
- return String(draft.value_text ?? '').trim().length > 0;
459
- }
460
-
461
- function buildProductAttributeSubmission(
462
- payload: CatalogAttributePayload | null,
463
- drafts: Record<number, ProductAttributeDraft>,
464
- localeCode?: string | null,
465
- ) {
466
- const errors: Record<number, string> = {};
467
- const values: Array<Record<string, unknown>> = [];
468
- const isPt = localeCode?.startsWith('pt');
469
-
470
- for (const group of payload?.groups ?? []) {
471
- for (const attribute of group.attributes as ProductAttributeMetadata[]) {
472
- const attributeId = Number(attribute.id ?? 0);
473
-
474
- if (!attributeId) {
475
- continue;
476
- }
477
-
478
- const normalized = normalizeProductAttributeDraft(attribute, drafts[attributeId]);
479
- const hasValue = hasMeaningfulProductAttributeValue(attribute, normalized);
480
- const relation = (attribute.category_attribute ?? {}) as CatalogRecord;
481
- const optionIds = new Set(
482
- (attribute.options ?? [])
483
- .map((option) => Number(option.id ?? 0))
484
- .filter((value) => value > 0),
485
- );
486
- const attributeLabel = String(
487
- attribute.name ?? attribute.label ?? attribute.slug ?? `#${attributeId}`,
488
- );
489
-
490
- if (
491
- normalized.attribute_option_id !== null &&
492
- normalized.attribute_option_id !== undefined &&
493
- !optionIds.has(Number(normalized.attribute_option_id))
494
- ) {
495
- errors[attributeId] = isPt
496
- ? `Selecione uma opção válida para ${attributeLabel}.`
497
- : `Select a valid option for ${attributeLabel}.`;
498
- continue;
499
- }
500
-
501
- if (relation.is_required === true && !hasValue) {
502
- errors[attributeId] = isPt
503
- ? `${attributeLabel} é obrigatório para esta categoria.`
504
- : `${attributeLabel} is required for this category.`;
505
- continue;
506
- }
507
-
508
- if (!hasValue) {
509
- continue;
510
- }
511
-
512
- values.push({
513
- attribute_id: attributeId,
514
- attribute_option_id: normalized.attribute_option_id,
515
- value_text: String(normalized.value_text ?? '').trim() || null,
516
- value_number: normalized.value_number,
517
- value_boolean: normalized.value_boolean,
518
- value_unit: String(normalized.value_unit ?? '').trim() || null,
519
- source_type: normalized.source_type,
520
- confidence_score: normalized.confidence_score,
521
- is_verified: normalized.is_verified,
522
- });
523
- }
524
- }
525
-
526
- return {
527
- errors,
528
- values,
529
- };
530
- }
531
-
532
- function buildCurrentProductAttributeValues(
533
- drafts: Record<number, ProductAttributeDraft>,
534
- ) {
535
- return Object.values(drafts).map((draft) => ({
536
- attribute_id: draft.attribute_id,
537
- attribute_option_id: draft.attribute_option_id,
538
- value_text: draft.value_text,
539
- value_number: draft.value_number,
540
- value_boolean: draft.value_boolean,
541
- value_unit: draft.value_unit,
542
- source_type: draft.source_type,
543
- confidence_score: draft.confidence_score,
544
- is_verified: draft.is_verified,
545
- }));
546
- }
326
+ return rawValue;
327
+ }
328
+
329
+ function buildAttributeDrafts(payload?: CatalogAttributePayload | null) {
330
+ const drafts: Record<number, ProductAttributeDraft> = {};
331
+
332
+ for (const group of payload?.groups ?? []) {
333
+ for (const attribute of group.attributes ?? []) {
334
+ const attributeId = Number(attribute.id ?? 0);
335
+ const value = (attribute.value ?? null) as CatalogRecord | null;
336
+
337
+ if (!attributeId) {
338
+ continue;
339
+ }
340
+
341
+ drafts[attributeId] = {
342
+ attribute_id: attributeId,
343
+ data_type: String(attribute.data_type ?? 'text'),
344
+ attribute_option_id:
345
+ value?.attribute_option_id === null ||
346
+ value?.attribute_option_id === undefined
347
+ ? null
348
+ : Number(value.attribute_option_id),
349
+ value_text: String(value?.value_text ?? ''),
350
+ value_number:
351
+ value?.value_number === null || value?.value_number === undefined
352
+ ? null
353
+ : Number(value.value_number),
354
+ value_boolean:
355
+ typeof value?.value_boolean === 'boolean' ? value.value_boolean : null,
356
+ value_unit: String(value?.value_unit ?? attribute.unit ?? ''),
357
+ source_type: String(value?.source_type ?? 'manual'),
358
+ confidence_score:
359
+ value?.confidence_score === null ||
360
+ value?.confidence_score === undefined
361
+ ? null
362
+ : Number(value.confidence_score),
363
+ is_verified: Boolean(value?.is_verified ?? false),
364
+ };
365
+ }
366
+ }
367
+
368
+ return drafts;
369
+ }
370
+
371
+ function createEmptyProductAttributeDraft(
372
+ attributeId: number,
373
+ dataType = 'text',
374
+ unit = '',
375
+ ): ProductAttributeDraft {
376
+ return {
377
+ attribute_id: attributeId,
378
+ data_type: dataType,
379
+ attribute_option_id: null,
380
+ value_text: '',
381
+ value_number: null,
382
+ value_boolean: null,
383
+ value_unit: unit,
384
+ source_type: 'manual',
385
+ confidence_score: null,
386
+ is_verified: false,
387
+ };
388
+ }
389
+
390
+ function normalizeProductAttributeDraft(
391
+ attribute: ProductAttributeMetadata,
392
+ draft?: ProductAttributeDraft,
393
+ ) {
394
+ const dataType = String(attribute.data_type ?? draft?.data_type ?? 'text');
395
+ const normalized = {
396
+ ...(draft ?? createEmptyProductAttributeDraft(Number(attribute.id), dataType, String(attribute.unit ?? ''))),
397
+ attribute_id: Number(attribute.id),
398
+ data_type: dataType,
399
+ value_unit: String(draft?.value_unit ?? attribute.unit ?? ''),
400
+ } satisfies ProductAttributeDraft;
401
+
402
+ if (dataType === 'option') {
403
+ return {
404
+ ...normalized,
405
+ value_text: '',
406
+ value_number: null,
407
+ value_boolean: null,
408
+ value_unit: '',
409
+ };
410
+ }
411
+
412
+ if (dataType === 'number') {
413
+ return {
414
+ ...normalized,
415
+ attribute_option_id: null,
416
+ value_text: '',
417
+ value_boolean: null,
418
+ };
419
+ }
420
+
421
+ if (dataType === 'boolean') {
422
+ return {
423
+ ...normalized,
424
+ attribute_option_id: null,
425
+ value_text: '',
426
+ value_number: null,
427
+ value_unit: '',
428
+ };
429
+ }
430
+
431
+ return {
432
+ ...normalized,
433
+ attribute_option_id: null,
434
+ value_number: null,
435
+ value_boolean: null,
436
+ value_unit: dataType === 'text' || dataType === 'long_text' ? '' : normalized.value_unit,
437
+ };
438
+ }
439
+
440
+ function hasMeaningfulProductAttributeValue(
441
+ attribute: ProductAttributeMetadata,
442
+ draft: ProductAttributeDraft,
443
+ ) {
444
+ const dataType = String(attribute.data_type ?? draft.data_type ?? 'text');
445
+
446
+ if (dataType === 'option') {
447
+ return draft.attribute_option_id !== null && draft.attribute_option_id !== undefined;
448
+ }
449
+
450
+ if (dataType === 'number') {
451
+ return draft.value_number !== null && draft.value_number !== undefined;
452
+ }
453
+
454
+ if (dataType === 'boolean') {
455
+ return draft.value_boolean !== null && draft.value_boolean !== undefined;
456
+ }
457
+
458
+ return String(draft.value_text ?? '').trim().length > 0;
459
+ }
460
+
461
+ function buildProductAttributeSubmission(
462
+ payload: CatalogAttributePayload | null,
463
+ drafts: Record<number, ProductAttributeDraft>,
464
+ localeCode?: string | null,
465
+ ) {
466
+ const errors: Record<number, string> = {};
467
+ const values: Array<Record<string, unknown>> = [];
468
+ const isPt = localeCode?.startsWith('pt');
469
+
470
+ for (const group of payload?.groups ?? []) {
471
+ for (const attribute of group.attributes as ProductAttributeMetadata[]) {
472
+ const attributeId = Number(attribute.id ?? 0);
473
+
474
+ if (!attributeId) {
475
+ continue;
476
+ }
477
+
478
+ const normalized = normalizeProductAttributeDraft(attribute, drafts[attributeId]);
479
+ const hasValue = hasMeaningfulProductAttributeValue(attribute, normalized);
480
+ const relation = (attribute.category_attribute ?? {}) as CatalogRecord;
481
+ const optionIds = new Set(
482
+ (attribute.options ?? [])
483
+ .map((option) => Number(option.id ?? 0))
484
+ .filter((value) => value > 0),
485
+ );
486
+ const attributeLabel = String(
487
+ attribute.name ?? attribute.label ?? attribute.slug ?? `#${attributeId}`,
488
+ );
489
+
490
+ if (
491
+ normalized.attribute_option_id !== null &&
492
+ normalized.attribute_option_id !== undefined &&
493
+ !optionIds.has(Number(normalized.attribute_option_id))
494
+ ) {
495
+ errors[attributeId] = isPt
496
+ ? `Selecione uma opção válida para ${attributeLabel}.`
497
+ : `Select a valid option for ${attributeLabel}.`;
498
+ continue;
499
+ }
500
+
501
+ if (relation.is_required === true && !hasValue) {
502
+ errors[attributeId] = isPt
503
+ ? `${attributeLabel} é obrigatório para esta categoria.`
504
+ : `${attributeLabel} is required for this category.`;
505
+ continue;
506
+ }
507
+
508
+ if (!hasValue) {
509
+ continue;
510
+ }
511
+
512
+ values.push({
513
+ attribute_id: attributeId,
514
+ attribute_option_id: normalized.attribute_option_id,
515
+ value_text: String(normalized.value_text ?? '').trim() || null,
516
+ value_number: normalized.value_number,
517
+ value_boolean: normalized.value_boolean,
518
+ value_unit: String(normalized.value_unit ?? '').trim() || null,
519
+ source_type: normalized.source_type,
520
+ confidence_score: normalized.confidence_score,
521
+ is_verified: normalized.is_verified,
522
+ });
523
+ }
524
+ }
525
+
526
+ return {
527
+ errors,
528
+ values,
529
+ };
530
+ }
531
+
532
+ function buildCurrentProductAttributeValues(
533
+ drafts: Record<number, ProductAttributeDraft>,
534
+ ) {
535
+ return Object.values(drafts).map((draft) => ({
536
+ attribute_id: draft.attribute_id,
537
+ attribute_option_id: draft.attribute_option_id,
538
+ value_text: draft.value_text,
539
+ value_number: draft.value_number,
540
+ value_boolean: draft.value_boolean,
541
+ value_unit: draft.value_unit,
542
+ source_type: draft.source_type,
543
+ confidence_score: draft.confidence_score,
544
+ is_verified: draft.is_verified,
545
+ }));
546
+ }
547
547
 
548
548
  function extractRecordId(record: CatalogRecord) {
549
549
  return Number(record.id ?? record.category_id ?? record.content_id ?? 0);
@@ -779,7 +779,7 @@ function CatalogRelationField({
779
779
  );
780
780
  }
781
781
 
782
- function CatalogUploadField({
782
+ function CatalogUploadField({
783
783
  field,
784
784
  value,
785
785
  onChange,
@@ -932,387 +932,387 @@ function CatalogUploadField({
932
932
 
933
933
  {isUploading ? <Progress value={progress} /> : null}
934
934
  </div>
935
- );
936
- }
937
-
938
- function ProductAttributesSection({
939
- open,
940
- recordId,
941
- categoryId,
942
- initialCategoryId,
943
- values,
944
- onChange,
945
- errors,
946
- onSchemaChange,
947
- }: {
948
- open: boolean;
949
- recordId: number | null;
950
- categoryId: number | null;
951
- initialCategoryId: number | null;
952
- values: Record<number, ProductAttributeDraft>;
953
- onChange: (next: Record<number, ProductAttributeDraft>) => void;
954
- errors: Record<number, string>;
955
- onSchemaChange: (payload: CatalogAttributePayload | null) => void;
956
- }) {
957
- const { request, currentLocaleCode } = useApp();
958
- const loadExistingValues =
959
- Boolean(recordId) && categoryId !== null && categoryId === initialCategoryId;
960
-
961
- const { data, isLoading } = useQuery<CatalogAttributePayload | null>({
962
- queryKey: [
963
- 'catalog-product-attributes-editor',
964
- recordId,
965
- categoryId,
966
- initialCategoryId,
967
- ],
968
- queryFn: async () => {
969
- if (!categoryId) {
970
- return null;
971
- }
972
-
973
- const response = await request({
974
- url: loadExistingValues
975
- ? `/catalog/products/${recordId}/attributes`
976
- : `/catalog/categories/${categoryId}/attributes`,
977
- method: 'GET',
978
- });
979
-
980
- return (response.data ?? null) as CatalogAttributePayload | null;
981
- },
982
- enabled: open && Boolean(categoryId),
983
- });
984
-
985
- useEffect(() => {
986
- onSchemaChange(data ?? null);
987
- }, [data, onSchemaChange]);
988
-
989
- useEffect(() => {
990
- if (!categoryId) {
991
- onSchemaChange(null);
992
- onChange({});
993
- return;
994
- }
995
-
996
- onChange(buildAttributeDrafts(data));
997
- }, [categoryId, data, onChange, onSchemaChange]);
998
-
999
- const updateDraft = (attributeId: number, patch: Partial<ProductAttributeDraft>) => {
1000
- const baseDraft = values[attributeId] ?? createEmptyProductAttributeDraft(attributeId);
1001
- onChange({
1002
- ...values,
1003
- [attributeId]: {
1004
- ...baseDraft,
1005
- ...patch,
1006
- },
1007
- });
1008
- };
1009
-
1010
- if (!categoryId) {
1011
- return (
1012
- <section className="space-y-2 rounded-xl border border-dashed p-4">
1013
- <h3 className="text-sm font-semibold">
1014
- {currentLocaleCode?.startsWith('pt')
1015
- ? 'Atributos estruturados'
1016
- : 'Structured attributes'}
1017
- </h3>
1018
- <p className="text-sm text-muted-foreground">
1019
- {currentLocaleCode?.startsWith('pt')
1020
- ? 'Selecione uma categoria para carregar os atributos do produto.'
1021
- : 'Select a category to load the product attributes.'}
1022
- </p>
1023
- </section>
1024
- );
1025
- }
1026
-
1027
- return (
1028
- <section className="space-y-4 rounded-xl border bg-muted/10 p-4">
1029
- <div className="space-y-1">
1030
- <h3 className="text-sm font-semibold">
1031
- {currentLocaleCode?.startsWith('pt')
1032
- ? 'Atributos estruturados'
1033
- : 'Structured attributes'}
1034
- </h3>
1035
- <p className="text-sm text-muted-foreground">
1036
- {currentLocaleCode?.startsWith('pt')
1037
- ? 'A categoria define os campos comparáveis e filtráveis do produto.'
1038
- : 'The category defines the product fields used for comparison and filters.'}
1039
- </p>
1040
- </div>
1041
-
1042
- {isLoading ? (
1043
- <div className="flex items-center gap-2 text-sm text-muted-foreground">
1044
- <Loader2 className="h-4 w-4 animate-spin" />
1045
- {currentLocaleCode?.startsWith('pt')
1046
- ? 'Carregando atributos...'
1047
- : 'Loading attributes...'}
1048
- </div>
1049
- ) : !(data?.groups?.length) ? (
1050
- <p className="text-sm text-muted-foreground">
1051
- {currentLocaleCode?.startsWith('pt')
1052
- ? 'Nenhum atributo configurado para esta categoria.'
1053
- : 'No attributes configured for this category.'}
1054
- </p>
1055
- ) : (
1056
- data.groups.map((group) => (
1057
- <div key={`${group.slug}-${group.id ?? 'general'}`} className="space-y-3">
1058
- <div>
1059
- <h4 className="text-sm font-medium">{group.name}</h4>
1060
- </div>
1061
-
1062
- <div className="grid gap-4">
1063
- {group.attributes.map((attribute) => {
1064
- const attributeId = Number(attribute.id);
1065
- const draft = normalizeProductAttributeDraft(
1066
- attribute as ProductAttributeMetadata,
1067
- values[attributeId],
1068
- );
1069
- const dataType = String(attribute.data_type ?? 'text');
1070
- const relation = (attribute.category_attribute ?? {}) as CatalogRecord;
1071
- const attributeBadges = [
1072
- relation.is_required
1073
- ? currentLocaleCode?.startsWith('pt')
1074
- ? 'Obrigatório'
1075
- : 'Required'
1076
- : null,
1077
- relation.is_highlight
1078
- ? currentLocaleCode?.startsWith('pt')
1079
- ? 'Destaque'
1080
- : 'Highlight'
1081
- : null,
1082
- relation.is_filter_visible
1083
- ? currentLocaleCode?.startsWith('pt')
1084
- ? 'Filtro'
1085
- : 'Filter'
1086
- : null,
1087
- relation.is_comparison_visible
1088
- ? currentLocaleCode?.startsWith('pt')
1089
- ? 'Comparação'
1090
- : 'Comparison'
1091
- : null,
1092
- ].filter((item): item is string => Boolean(item));
1093
-
1094
- return (
1095
- <div key={attributeId} className="rounded-lg border bg-background p-3">
1096
- <div className="mb-3 space-y-1">
1097
- <div className="flex items-center justify-between gap-3">
1098
- <div className="space-y-1">
1099
- <div className="font-medium">
1100
- {String(attribute.name ?? attribute.label ?? attribute.slug)}
1101
- {relation.is_required ? (
1102
- <span className="ml-1 text-destructive">*</span>
1103
- ) : null}
1104
- </div>
1105
- {attributeBadges.length ? (
1106
- <div className="flex flex-wrap gap-2">
1107
- {attributeBadges.map((badge) => (
1108
- <Badge key={`${attributeId}-${badge}`} variant="secondary">
1109
- {badge}
1110
- </Badge>
1111
- ))}
1112
- </div>
1113
- ) : null}
1114
- </div>
1115
- {attribute.unit ? (
1116
- <span className="text-xs text-muted-foreground">
1117
- {String(attribute.unit)}
1118
- </span>
1119
- ) : null}
1120
- </div>
1121
- {attribute.description ? (
1122
- <p className="text-xs text-muted-foreground">
1123
- {String(attribute.description)}
1124
- </p>
1125
- ) : null}
1126
- {errors[attributeId] ? (
1127
- <p className="text-xs font-medium text-destructive">
1128
- {errors[attributeId]}
1129
- </p>
1130
- ) : null}
1131
- </div>
1132
-
1133
- <div className="grid gap-3 sm:grid-cols-2">
1134
- {dataType === 'option' ? (
1135
- <div className="space-y-2 sm:col-span-2">
1136
- <FormLabel>
1137
- {currentLocaleCode?.startsWith('pt') ? 'Opção' : 'Option'}
1138
- </FormLabel>
1139
- <Select
1140
- value={
1141
- draft?.attribute_option_id
1142
- ? String(draft.attribute_option_id)
1143
- : ''
1144
- }
1145
- onValueChange={(next) =>
1146
- updateDraft(attributeId, {
1147
- data_type: dataType,
1148
- attribute_option_id: next ? Number(next) : null,
1149
- })
1150
- }
1151
- >
1152
- <SelectTrigger>
1153
- <SelectValue
1154
- placeholder={
1155
- currentLocaleCode?.startsWith('pt')
1156
- ? 'Selecione uma opção'
1157
- : 'Select an option'
1158
- }
1159
- />
1160
- </SelectTrigger>
1161
- <SelectContent>
1162
- {(attribute.options ?? []).map((option) => (
1163
- <SelectItem key={String(option.id)} value={String(option.id)}>
1164
- {String(option.label ?? option.option_value)}
1165
- </SelectItem>
1166
- ))}
1167
- </SelectContent>
1168
- </Select>
1169
- </div>
1170
- ) : null}
1171
-
1172
- {dataType === 'text' || dataType === 'long_text' ? (
1173
- <div className="space-y-2 sm:col-span-2">
1174
- <FormLabel>{currentLocaleCode?.startsWith('pt') ? 'Valor' : 'Value'}</FormLabel>
1175
- {dataType === 'long_text' ? (
1176
- <Textarea
1177
- value={draft?.value_text ?? ''}
1178
- onChange={(event) =>
1179
- updateDraft(attributeId, {
1180
- data_type: dataType,
1181
- value_text: event.target.value,
1182
- })
1183
- }
1184
- />
1185
- ) : (
1186
- <Input
1187
- value={draft?.value_text ?? ''}
1188
- onChange={(event) =>
1189
- updateDraft(attributeId, {
1190
- data_type: dataType,
1191
- value_text: event.target.value,
1192
- })
1193
- }
1194
- />
1195
- )}
1196
- </div>
1197
- ) : null}
1198
-
1199
- {dataType === 'number' ? (
1200
- <>
1201
- <div className="space-y-2">
1202
- <FormLabel>{currentLocaleCode?.startsWith('pt') ? 'Número' : 'Number'}</FormLabel>
1203
- <Input
1204
- type="number"
1205
- value={
1206
- draft?.value_number === null ||
1207
- draft?.value_number === undefined
1208
- ? ''
1209
- : String(draft.value_number)
1210
- }
1211
- onChange={(event) =>
1212
- updateDraft(attributeId, {
1213
- data_type: dataType,
1214
- value_number:
1215
- event.target.value === ''
1216
- ? null
1217
- : Number(event.target.value),
1218
- })
1219
- }
1220
- />
1221
- </div>
1222
- <div className="space-y-2">
1223
- <FormLabel>{currentLocaleCode?.startsWith('pt') ? 'Unidade' : 'Unit'}</FormLabel>
1224
- <Input
1225
- value={draft?.value_unit ?? ''}
1226
- onChange={(event) =>
1227
- updateDraft(attributeId, {
1228
- data_type: dataType,
1229
- value_unit: event.target.value,
1230
- })
1231
- }
1232
- />
1233
- </div>
1234
- </>
1235
- ) : null}
1236
-
1237
- {dataType === 'boolean' ? (
1238
- <div className="space-y-2 sm:col-span-2">
1239
- <FormLabel>{currentLocaleCode?.startsWith('pt') ? 'Valor' : 'Value'}</FormLabel>
1240
- <Select
1241
- value={
1242
- draft?.value_boolean === null ||
1243
- draft?.value_boolean === undefined
1244
- ? ''
1245
- : draft.value_boolean
1246
- ? 'true'
1247
- : 'false'
1248
- }
1249
- onValueChange={(next) =>
1250
- updateDraft(attributeId, {
1251
- data_type: dataType,
1252
- value_boolean:
1253
- next === ''
1254
- ? null
1255
- : next === 'true',
1256
- })
1257
- }
1258
- >
1259
- <SelectTrigger>
1260
- <SelectValue
1261
- placeholder={
1262
- currentLocaleCode?.startsWith('pt')
1263
- ? 'Selecione um valor'
1264
- : 'Select a value'
1265
- }
1266
- />
1267
- </SelectTrigger>
1268
- <SelectContent>
1269
- <SelectItem value="true">
1270
- {currentLocaleCode?.startsWith('pt') ? 'Sim' : 'Yes'}
1271
- </SelectItem>
1272
- <SelectItem value="false">
1273
- {currentLocaleCode?.startsWith('pt') ? 'Não' : 'No'}
1274
- </SelectItem>
1275
- </SelectContent>
1276
- </Select>
1277
- </div>
1278
- ) : null}
1279
-
1280
- <div className="flex items-center justify-between rounded-md border px-3 py-2 sm:col-span-2">
1281
- <div>
1282
- <div className="text-sm font-medium">
1283
- {currentLocaleCode?.startsWith('pt')
1284
- ? 'Valor verificado'
1285
- : 'Verified value'}
1286
- </div>
1287
- <div className="text-xs text-muted-foreground">
1288
- {currentLocaleCode?.startsWith('pt')
1289
- ? 'Marque quando o dado já tiver sido validado.'
1290
- : 'Enable when the value has been validated.'}
1291
- </div>
1292
- </div>
1293
- <Switch
1294
- checked={Boolean(draft?.is_verified)}
1295
- onCheckedChange={(checked) =>
1296
- updateDraft(attributeId, {
1297
- data_type: dataType,
1298
- is_verified: checked,
1299
- })
1300
- }
1301
- />
1302
- </div>
1303
- </div>
1304
- </div>
1305
- );
1306
- })}
1307
- </div>
1308
- </div>
1309
- ))
1310
- )}
1311
- </section>
1312
- );
1313
- }
1314
-
1315
- export function CatalogResourceFormSheet({
935
+ );
936
+ }
937
+
938
+ function ProductAttributesSection({
939
+ open,
940
+ recordId,
941
+ categoryId,
942
+ initialCategoryId,
943
+ values,
944
+ onChange,
945
+ errors,
946
+ onSchemaChange,
947
+ }: {
948
+ open: boolean;
949
+ recordId: number | null;
950
+ categoryId: number | null;
951
+ initialCategoryId: number | null;
952
+ values: Record<number, ProductAttributeDraft>;
953
+ onChange: (next: Record<number, ProductAttributeDraft>) => void;
954
+ errors: Record<number, string>;
955
+ onSchemaChange: (payload: CatalogAttributePayload | null) => void;
956
+ }) {
957
+ const { request, currentLocaleCode } = useApp();
958
+ const loadExistingValues =
959
+ Boolean(recordId) && categoryId !== null && categoryId === initialCategoryId;
960
+
961
+ const { data, isLoading } = useQuery<CatalogAttributePayload | null>({
962
+ queryKey: [
963
+ 'catalog-product-attributes-editor',
964
+ recordId,
965
+ categoryId,
966
+ initialCategoryId,
967
+ ],
968
+ queryFn: async () => {
969
+ if (!categoryId) {
970
+ return null;
971
+ }
972
+
973
+ const response = await request({
974
+ url: loadExistingValues
975
+ ? `/catalog/products/${recordId}/attributes`
976
+ : `/catalog/categories/${categoryId}/attributes`,
977
+ method: 'GET',
978
+ });
979
+
980
+ return (response.data ?? null) as CatalogAttributePayload | null;
981
+ },
982
+ enabled: open && Boolean(categoryId),
983
+ });
984
+
985
+ useEffect(() => {
986
+ onSchemaChange(data ?? null);
987
+ }, [data, onSchemaChange]);
988
+
989
+ useEffect(() => {
990
+ if (!categoryId) {
991
+ onSchemaChange(null);
992
+ onChange({});
993
+ return;
994
+ }
995
+
996
+ onChange(buildAttributeDrafts(data));
997
+ }, [categoryId, data, onChange, onSchemaChange]);
998
+
999
+ const updateDraft = (attributeId: number, patch: Partial<ProductAttributeDraft>) => {
1000
+ const baseDraft = values[attributeId] ?? createEmptyProductAttributeDraft(attributeId);
1001
+ onChange({
1002
+ ...values,
1003
+ [attributeId]: {
1004
+ ...baseDraft,
1005
+ ...patch,
1006
+ },
1007
+ });
1008
+ };
1009
+
1010
+ if (!categoryId) {
1011
+ return (
1012
+ <section className="space-y-2 rounded-xl border border-dashed p-4">
1013
+ <h3 className="text-sm font-semibold">
1014
+ {currentLocaleCode?.startsWith('pt')
1015
+ ? 'Atributos estruturados'
1016
+ : 'Structured attributes'}
1017
+ </h3>
1018
+ <p className="text-sm text-muted-foreground">
1019
+ {currentLocaleCode?.startsWith('pt')
1020
+ ? 'Selecione uma categoria para carregar os atributos do produto.'
1021
+ : 'Select a category to load the product attributes.'}
1022
+ </p>
1023
+ </section>
1024
+ );
1025
+ }
1026
+
1027
+ return (
1028
+ <section className="space-y-4 rounded-xl border bg-muted/10 p-4">
1029
+ <div className="space-y-1">
1030
+ <h3 className="text-sm font-semibold">
1031
+ {currentLocaleCode?.startsWith('pt')
1032
+ ? 'Atributos estruturados'
1033
+ : 'Structured attributes'}
1034
+ </h3>
1035
+ <p className="text-sm text-muted-foreground">
1036
+ {currentLocaleCode?.startsWith('pt')
1037
+ ? 'A categoria define os campos comparáveis e filtráveis do produto.'
1038
+ : 'The category defines the product fields used for comparison and filters.'}
1039
+ </p>
1040
+ </div>
1041
+
1042
+ {isLoading ? (
1043
+ <div className="flex items-center gap-2 text-sm text-muted-foreground">
1044
+ <Loader2 className="h-4 w-4 animate-spin" />
1045
+ {currentLocaleCode?.startsWith('pt')
1046
+ ? 'Carregando atributos...'
1047
+ : 'Loading attributes...'}
1048
+ </div>
1049
+ ) : !(data?.groups?.length) ? (
1050
+ <p className="text-sm text-muted-foreground">
1051
+ {currentLocaleCode?.startsWith('pt')
1052
+ ? 'Nenhum atributo configurado para esta categoria.'
1053
+ : 'No attributes configured for this category.'}
1054
+ </p>
1055
+ ) : (
1056
+ data.groups.map((group) => (
1057
+ <div key={`${group.slug}-${group.id ?? 'general'}`} className="space-y-3">
1058
+ <div>
1059
+ <h4 className="text-sm font-medium">{group.name}</h4>
1060
+ </div>
1061
+
1062
+ <div className="grid gap-4">
1063
+ {group.attributes.map((attribute) => {
1064
+ const attributeId = Number(attribute.id);
1065
+ const draft = normalizeProductAttributeDraft(
1066
+ attribute as ProductAttributeMetadata,
1067
+ values[attributeId],
1068
+ );
1069
+ const dataType = String(attribute.data_type ?? 'text');
1070
+ const relation = (attribute.category_attribute ?? {}) as CatalogRecord;
1071
+ const attributeBadges = [
1072
+ relation.is_required
1073
+ ? currentLocaleCode?.startsWith('pt')
1074
+ ? 'Obrigatório'
1075
+ : 'Required'
1076
+ : null,
1077
+ relation.is_highlight
1078
+ ? currentLocaleCode?.startsWith('pt')
1079
+ ? 'Destaque'
1080
+ : 'Highlight'
1081
+ : null,
1082
+ relation.is_filter_visible
1083
+ ? currentLocaleCode?.startsWith('pt')
1084
+ ? 'Filtro'
1085
+ : 'Filter'
1086
+ : null,
1087
+ relation.is_comparison_visible
1088
+ ? currentLocaleCode?.startsWith('pt')
1089
+ ? 'Comparação'
1090
+ : 'Comparison'
1091
+ : null,
1092
+ ].filter((item): item is string => Boolean(item));
1093
+
1094
+ return (
1095
+ <div key={attributeId} className="rounded-lg border bg-background p-3">
1096
+ <div className="mb-3 space-y-1">
1097
+ <div className="flex items-center justify-between gap-3">
1098
+ <div className="space-y-1">
1099
+ <div className="font-medium">
1100
+ {String(attribute.name ?? attribute.label ?? attribute.slug)}
1101
+ {relation.is_required ? (
1102
+ <span className="ml-1 text-destructive">*</span>
1103
+ ) : null}
1104
+ </div>
1105
+ {attributeBadges.length ? (
1106
+ <div className="flex flex-wrap gap-2">
1107
+ {attributeBadges.map((badge) => (
1108
+ <Badge key={`${attributeId}-${badge}`} variant="secondary">
1109
+ {badge}
1110
+ </Badge>
1111
+ ))}
1112
+ </div>
1113
+ ) : null}
1114
+ </div>
1115
+ {attribute.unit ? (
1116
+ <span className="text-xs text-muted-foreground">
1117
+ {String(attribute.unit)}
1118
+ </span>
1119
+ ) : null}
1120
+ </div>
1121
+ {attribute.description ? (
1122
+ <p className="text-xs text-muted-foreground">
1123
+ {String(attribute.description)}
1124
+ </p>
1125
+ ) : null}
1126
+ {errors[attributeId] ? (
1127
+ <p className="text-xs font-medium text-destructive">
1128
+ {errors[attributeId]}
1129
+ </p>
1130
+ ) : null}
1131
+ </div>
1132
+
1133
+ <div className="grid gap-3 sm:grid-cols-2">
1134
+ {dataType === 'option' ? (
1135
+ <div className="space-y-2 sm:col-span-2">
1136
+ <FormLabel>
1137
+ {currentLocaleCode?.startsWith('pt') ? 'Opção' : 'Option'}
1138
+ </FormLabel>
1139
+ <Select
1140
+ value={
1141
+ draft?.attribute_option_id
1142
+ ? String(draft.attribute_option_id)
1143
+ : ''
1144
+ }
1145
+ onValueChange={(next) =>
1146
+ updateDraft(attributeId, {
1147
+ data_type: dataType,
1148
+ attribute_option_id: next ? Number(next) : null,
1149
+ })
1150
+ }
1151
+ >
1152
+ <SelectTrigger>
1153
+ <SelectValue
1154
+ placeholder={
1155
+ currentLocaleCode?.startsWith('pt')
1156
+ ? 'Selecione uma opção'
1157
+ : 'Select an option'
1158
+ }
1159
+ />
1160
+ </SelectTrigger>
1161
+ <SelectContent>
1162
+ {(attribute.options ?? []).map((option) => (
1163
+ <SelectItem key={String(option.id)} value={String(option.id)}>
1164
+ {String(option.label ?? option.option_value)}
1165
+ </SelectItem>
1166
+ ))}
1167
+ </SelectContent>
1168
+ </Select>
1169
+ </div>
1170
+ ) : null}
1171
+
1172
+ {dataType === 'text' || dataType === 'long_text' ? (
1173
+ <div className="space-y-2 sm:col-span-2">
1174
+ <FormLabel>{currentLocaleCode?.startsWith('pt') ? 'Valor' : 'Value'}</FormLabel>
1175
+ {dataType === 'long_text' ? (
1176
+ <Textarea
1177
+ value={draft?.value_text ?? ''}
1178
+ onChange={(event) =>
1179
+ updateDraft(attributeId, {
1180
+ data_type: dataType,
1181
+ value_text: event.target.value,
1182
+ })
1183
+ }
1184
+ />
1185
+ ) : (
1186
+ <Input
1187
+ value={draft?.value_text ?? ''}
1188
+ onChange={(event) =>
1189
+ updateDraft(attributeId, {
1190
+ data_type: dataType,
1191
+ value_text: event.target.value,
1192
+ })
1193
+ }
1194
+ />
1195
+ )}
1196
+ </div>
1197
+ ) : null}
1198
+
1199
+ {dataType === 'number' ? (
1200
+ <>
1201
+ <div className="space-y-2">
1202
+ <FormLabel>{currentLocaleCode?.startsWith('pt') ? 'Número' : 'Number'}</FormLabel>
1203
+ <Input
1204
+ type="number"
1205
+ value={
1206
+ draft?.value_number === null ||
1207
+ draft?.value_number === undefined
1208
+ ? ''
1209
+ : String(draft.value_number)
1210
+ }
1211
+ onChange={(event) =>
1212
+ updateDraft(attributeId, {
1213
+ data_type: dataType,
1214
+ value_number:
1215
+ event.target.value === ''
1216
+ ? null
1217
+ : Number(event.target.value),
1218
+ })
1219
+ }
1220
+ />
1221
+ </div>
1222
+ <div className="space-y-2">
1223
+ <FormLabel>{currentLocaleCode?.startsWith('pt') ? 'Unidade' : 'Unit'}</FormLabel>
1224
+ <Input
1225
+ value={draft?.value_unit ?? ''}
1226
+ onChange={(event) =>
1227
+ updateDraft(attributeId, {
1228
+ data_type: dataType,
1229
+ value_unit: event.target.value,
1230
+ })
1231
+ }
1232
+ />
1233
+ </div>
1234
+ </>
1235
+ ) : null}
1236
+
1237
+ {dataType === 'boolean' ? (
1238
+ <div className="space-y-2 sm:col-span-2">
1239
+ <FormLabel>{currentLocaleCode?.startsWith('pt') ? 'Valor' : 'Value'}</FormLabel>
1240
+ <Select
1241
+ value={
1242
+ draft?.value_boolean === null ||
1243
+ draft?.value_boolean === undefined
1244
+ ? ''
1245
+ : draft.value_boolean
1246
+ ? 'true'
1247
+ : 'false'
1248
+ }
1249
+ onValueChange={(next) =>
1250
+ updateDraft(attributeId, {
1251
+ data_type: dataType,
1252
+ value_boolean:
1253
+ next === ''
1254
+ ? null
1255
+ : next === 'true',
1256
+ })
1257
+ }
1258
+ >
1259
+ <SelectTrigger>
1260
+ <SelectValue
1261
+ placeholder={
1262
+ currentLocaleCode?.startsWith('pt')
1263
+ ? 'Selecione um valor'
1264
+ : 'Select a value'
1265
+ }
1266
+ />
1267
+ </SelectTrigger>
1268
+ <SelectContent>
1269
+ <SelectItem value="true">
1270
+ {currentLocaleCode?.startsWith('pt') ? 'Sim' : 'Yes'}
1271
+ </SelectItem>
1272
+ <SelectItem value="false">
1273
+ {currentLocaleCode?.startsWith('pt') ? 'Não' : 'No'}
1274
+ </SelectItem>
1275
+ </SelectContent>
1276
+ </Select>
1277
+ </div>
1278
+ ) : null}
1279
+
1280
+ <div className="flex items-center justify-between rounded-md border px-3 py-2 sm:col-span-2">
1281
+ <div>
1282
+ <div className="text-sm font-medium">
1283
+ {currentLocaleCode?.startsWith('pt')
1284
+ ? 'Valor verificado'
1285
+ : 'Verified value'}
1286
+ </div>
1287
+ <div className="text-xs text-muted-foreground">
1288
+ {currentLocaleCode?.startsWith('pt')
1289
+ ? 'Marque quando o dado já tiver sido validado.'
1290
+ : 'Enable when the value has been validated.'}
1291
+ </div>
1292
+ </div>
1293
+ <Switch
1294
+ checked={Boolean(draft?.is_verified)}
1295
+ onCheckedChange={(checked) =>
1296
+ updateDraft(attributeId, {
1297
+ data_type: dataType,
1298
+ is_verified: checked,
1299
+ })
1300
+ }
1301
+ />
1302
+ </div>
1303
+ </div>
1304
+ </div>
1305
+ );
1306
+ })}
1307
+ </div>
1308
+ </div>
1309
+ ))
1310
+ )}
1311
+ </section>
1312
+ );
1313
+ }
1314
+
1315
+ export function CatalogResourceFormSheet({
1316
1316
  open,
1317
1317
  onOpenChange,
1318
1318
  resource,
@@ -1322,28 +1322,28 @@ export function CatalogResourceFormSheet({
1322
1322
  recordId,
1323
1323
  onSuccess,
1324
1324
  }: FormSheetProps) {
1325
- const { request, currentLocaleCode } = useApp();
1326
- const t = useTranslations('catalog');
1327
- const isEditing = Boolean(recordId);
1328
- const [productAttributeValues, setProductAttributeValues] = useState<
1329
- Record<number, ProductAttributeDraft>
1330
- >({});
1331
- const [productAttributeSchema, setProductAttributeSchema] =
1332
- useState<CatalogAttributePayload | null>(null);
1333
- const [productAttributeErrors, setProductAttributeErrors] = useState<
1334
- Record<number, string>
1335
- >({});
1336
- const formSchema = useMemo(
1337
- () => buildFormSchema(resourceConfig),
1338
- [resourceConfig]
1339
- );
1325
+ const { request, currentLocaleCode } = useApp();
1326
+ const t = useTranslations('catalog');
1327
+ const isEditing = Boolean(recordId);
1328
+ const [productAttributeValues, setProductAttributeValues] = useState<
1329
+ Record<number, ProductAttributeDraft>
1330
+ >({});
1331
+ const [productAttributeSchema, setProductAttributeSchema] =
1332
+ useState<CatalogAttributePayload | null>(null);
1333
+ const [productAttributeErrors, setProductAttributeErrors] = useState<
1334
+ Record<number, string>
1335
+ >({});
1336
+ const formSchema = useMemo(
1337
+ () => buildFormSchema(resourceConfig),
1338
+ [resourceConfig]
1339
+ );
1340
1340
 
1341
1341
  const form = useForm<CatalogFormValues>({
1342
1342
  resolver: zodResolver(formSchema),
1343
1343
  defaultValues: buildDefaultValues(resourceConfig, resourceConfig.template),
1344
1344
  });
1345
1345
 
1346
- const { data: currentRecord, isLoading } = useQuery<CatalogRecord>({
1346
+ const { data: currentRecord, isLoading } = useQuery<CatalogRecord>({
1347
1347
  queryKey: ['catalog-record-details', resource, recordId],
1348
1348
  queryFn: async () => {
1349
1349
  const response = await request({
@@ -1352,185 +1352,185 @@ export function CatalogResourceFormSheet({
1352
1352
  });
1353
1353
 
1354
1354
  return response.data as CatalogRecord;
1355
- },
1356
- enabled: open && Boolean(recordId),
1357
- });
1358
- const selectedCategoryId =
1359
- resource === 'products'
1360
- ? Number(form.watch('catalog_category_id') ?? 0) || null
1361
- : null;
1362
- const initialCategoryId =
1363
- resource === 'products'
1364
- ? Number(currentRecord?.catalog_category_id ?? 0) || null
1365
- : null;
1366
-
1367
- useEffect(() => {
1368
- if (!open) {
1369
- setProductAttributeValues({});
1370
- setProductAttributeSchema(null);
1371
- setProductAttributeErrors({});
1372
- return;
1373
- }
1374
-
1375
- const payload = currentRecord || resourceConfig.template;
1376
- form.reset(buildDefaultValues(resourceConfig, payload));
1377
- }, [currentRecord, form, open, resourceConfig]);
1378
-
1379
- useEffect(() => {
1380
- setProductAttributeErrors({});
1381
- }, [selectedCategoryId]);
1382
-
1383
- const submitLabel = isEditing
1384
- ? getCatalogLocalizedText(resourceConfig.editActionLabel, currentLocaleCode)
1385
- : getCatalogLocalizedText(
1386
- resourceConfig.createActionLabel,
1387
- currentLocaleCode
1388
- );
1389
-
1390
- const handleSubmit = async (values: CatalogFormValues) => {
1391
- try {
1392
- const payload: CatalogRecord = {};
1393
-
1394
- for (const section of resourceConfig.formSections) {
1395
- for (const field of section.fields) {
1396
- payload[field.key] = normalizeSubmitValue(field, values[field.key]);
1397
- }
1398
- }
1399
-
1400
- let attributeSubmission:
1401
- | ReturnType<typeof buildProductAttributeSubmission>
1402
- | null = null;
1403
-
1404
- if (resource === 'products') {
1405
- if (selectedCategoryId && !productAttributeSchema) {
1406
- toast.error(
1407
- currentLocaleCode?.startsWith('pt')
1408
- ? 'Aguarde o carregamento dos atributos da categoria antes de salvar.'
1409
- : 'Wait for the category attributes to load before saving.',
1410
- );
1411
- return;
1412
- }
1413
-
1414
- attributeSubmission = buildProductAttributeSubmission(
1415
- productAttributeSchema,
1416
- productAttributeValues,
1417
- currentLocaleCode,
1418
- );
1419
-
1420
- if (Object.keys(attributeSubmission.errors).length > 0) {
1421
- setProductAttributeErrors(attributeSubmission.errors);
1422
- toast.error(
1423
- Object.values(attributeSubmission.errors)[0] ?? t('toasts.saveError'),
1424
- );
1425
- return;
1426
- }
1427
- }
1428
-
1429
- const response = await request({
1430
- url: isEditing
1431
- ? `/catalog/${resource}/${recordId}`
1432
- : `/catalog/${resource}`,
1433
- method: isEditing ? 'PATCH' : 'POST',
1434
- data: payload,
1435
- });
1436
-
1437
- const savedRecord = (response.data || payload) as CatalogRecord;
1438
-
1439
- if (resource === 'products') {
1440
- const savedId = extractRecordId(savedRecord);
1441
-
1442
- if (savedId) {
1443
- await request({
1444
- url: `/catalog/products/${savedId}/attributes`,
1445
- method: 'PUT',
1446
- data: {
1447
- values: attributeSubmission?.values ?? [],
1448
- },
1449
- });
1450
-
1451
- setProductAttributeErrors({});
1452
- }
1453
- }
1454
-
1455
- toast.success(t('toasts.saveSuccess', { resource: resourceTitle }));
1456
- onOpenChange(false);
1457
- await onSuccess(savedRecord);
1355
+ },
1356
+ enabled: open && Boolean(recordId),
1357
+ });
1358
+ const selectedCategoryId =
1359
+ resource === 'products'
1360
+ ? Number(form.watch('catalog_category_id') ?? 0) || null
1361
+ : null;
1362
+ const initialCategoryId =
1363
+ resource === 'products'
1364
+ ? Number(currentRecord?.catalog_category_id ?? 0) || null
1365
+ : null;
1366
+
1367
+ useEffect(() => {
1368
+ if (!open) {
1369
+ setProductAttributeValues({});
1370
+ setProductAttributeSchema(null);
1371
+ setProductAttributeErrors({});
1372
+ return;
1373
+ }
1374
+
1375
+ const payload = currentRecord || resourceConfig.template;
1376
+ form.reset(buildDefaultValues(resourceConfig, payload));
1377
+ }, [currentRecord, form, open, resourceConfig]);
1378
+
1379
+ useEffect(() => {
1380
+ setProductAttributeErrors({});
1381
+ }, [selectedCategoryId]);
1382
+
1383
+ const submitLabel = isEditing
1384
+ ? getCatalogLocalizedText(resourceConfig.editActionLabel, currentLocaleCode)
1385
+ : getCatalogLocalizedText(
1386
+ resourceConfig.createActionLabel,
1387
+ currentLocaleCode
1388
+ );
1389
+
1390
+ const handleSubmit = async (values: CatalogFormValues) => {
1391
+ try {
1392
+ const payload: CatalogRecord = {};
1393
+
1394
+ for (const section of resourceConfig.formSections) {
1395
+ for (const field of section.fields) {
1396
+ payload[field.key] = normalizeSubmitValue(field, values[field.key]);
1397
+ }
1398
+ }
1399
+
1400
+ let attributeSubmission:
1401
+ | ReturnType<typeof buildProductAttributeSubmission>
1402
+ | null = null;
1403
+
1404
+ if (resource === 'products') {
1405
+ if (selectedCategoryId && !productAttributeSchema) {
1406
+ toast.error(
1407
+ currentLocaleCode?.startsWith('pt')
1408
+ ? 'Aguarde o carregamento dos atributos da categoria antes de salvar.'
1409
+ : 'Wait for the category attributes to load before saving.',
1410
+ );
1411
+ return;
1412
+ }
1413
+
1414
+ attributeSubmission = buildProductAttributeSubmission(
1415
+ productAttributeSchema,
1416
+ productAttributeValues,
1417
+ currentLocaleCode,
1418
+ );
1419
+
1420
+ if (Object.keys(attributeSubmission.errors).length > 0) {
1421
+ setProductAttributeErrors(attributeSubmission.errors);
1422
+ toast.error(
1423
+ Object.values(attributeSubmission.errors)[0] ?? t('toasts.saveError'),
1424
+ );
1425
+ return;
1426
+ }
1427
+ }
1428
+
1429
+ const response = await request({
1430
+ url: isEditing
1431
+ ? `/catalog/${resource}/${recordId}`
1432
+ : `/catalog/${resource}`,
1433
+ method: isEditing ? 'PATCH' : 'POST',
1434
+ data: payload,
1435
+ });
1436
+
1437
+ const savedRecord = (response.data || payload) as CatalogRecord;
1438
+
1439
+ if (resource === 'products') {
1440
+ const savedId = extractRecordId(savedRecord);
1441
+
1442
+ if (savedId) {
1443
+ await request({
1444
+ url: `/catalog/products/${savedId}/attributes`,
1445
+ method: 'PUT',
1446
+ data: {
1447
+ values: attributeSubmission?.values ?? [],
1448
+ },
1449
+ });
1450
+
1451
+ setProductAttributeErrors({});
1452
+ }
1453
+ }
1454
+
1455
+ toast.success(t('toasts.saveSuccess', { resource: resourceTitle }));
1456
+ onOpenChange(false);
1457
+ await onSuccess(savedRecord);
1458
1458
  } catch (error) {
1459
1459
  toast.error(
1460
1460
  error instanceof Error ? error.message : t('toasts.saveError')
1461
1461
  );
1462
1462
  }
1463
- };
1464
-
1465
- const handleApplyAiSuggestion = (suggestion: {
1466
- fields?: Record<string, unknown>;
1467
- product_attributes?: Array<Record<string, unknown>>;
1468
- }) => {
1469
- const fieldMap = new Map(
1470
- resourceConfig.formSections.flatMap((section) =>
1471
- section.fields.map((field) => [field.key, field] as const),
1472
- ),
1473
- );
1474
-
1475
- for (const [key, value] of Object.entries(suggestion.fields ?? {})) {
1476
- const field = fieldMap.get(key);
1477
-
1478
- if (!field) {
1479
- continue;
1480
- }
1481
-
1482
- form.setValue(
1483
- key,
1484
- serializeInitialValue(field, value) as never,
1485
- { shouldDirty: true, shouldTouch: true },
1486
- );
1487
- }
1488
-
1489
- if (resource === 'products' && suggestion.product_attributes?.length) {
1490
- const nextDrafts = { ...productAttributeValues };
1491
-
1492
- for (const attribute of suggestion.product_attributes) {
1493
- const attributeId = Number(attribute.attribute_id ?? 0);
1494
-
1495
- if (!attributeId) {
1496
- continue;
1497
- }
1498
-
1499
- nextDrafts[attributeId] = {
1500
- attribute_id: attributeId,
1501
- data_type: String(attribute.data_type ?? nextDrafts[attributeId]?.data_type ?? 'text'),
1502
- attribute_option_id:
1503
- attribute.attribute_option_id === null ||
1504
- attribute.attribute_option_id === undefined
1505
- ? null
1506
- : Number(attribute.attribute_option_id),
1507
- value_text: String(attribute.value_text ?? ''),
1508
- value_number:
1509
- attribute.value_number === null || attribute.value_number === undefined
1510
- ? null
1511
- : Number(attribute.value_number),
1512
- value_boolean:
1513
- typeof attribute.value_boolean === 'boolean'
1514
- ? attribute.value_boolean
1515
- : null,
1516
- value_unit: String(attribute.value_unit ?? ''),
1517
- source_type: String(attribute.source_type ?? 'manual'),
1518
- confidence_score:
1519
- attribute.confidence_score === null ||
1520
- attribute.confidence_score === undefined
1521
- ? null
1522
- : Number(attribute.confidence_score),
1523
- is_verified: Boolean(attribute.is_verified ?? false),
1524
- };
1525
- }
1526
-
1527
- setProductAttributeValues(nextDrafts);
1528
- setProductAttributeErrors({});
1529
- }
1530
- };
1531
-
1532
- return (
1533
- <Sheet open={open} onOpenChange={onOpenChange}>
1463
+ };
1464
+
1465
+ const handleApplyAiSuggestion = (suggestion: {
1466
+ fields?: Record<string, unknown>;
1467
+ product_attributes?: Array<Record<string, unknown>>;
1468
+ }) => {
1469
+ const fieldMap = new Map(
1470
+ resourceConfig.formSections.flatMap((section) =>
1471
+ section.fields.map((field) => [field.key, field] as const),
1472
+ ),
1473
+ );
1474
+
1475
+ for (const [key, value] of Object.entries(suggestion.fields ?? {})) {
1476
+ const field = fieldMap.get(key);
1477
+
1478
+ if (!field) {
1479
+ continue;
1480
+ }
1481
+
1482
+ form.setValue(
1483
+ key,
1484
+ serializeInitialValue(field, value) as never,
1485
+ { shouldDirty: true, shouldTouch: true },
1486
+ );
1487
+ }
1488
+
1489
+ if (resource === 'products' && suggestion.product_attributes?.length) {
1490
+ const nextDrafts = { ...productAttributeValues };
1491
+
1492
+ for (const attribute of suggestion.product_attributes) {
1493
+ const attributeId = Number(attribute.attribute_id ?? 0);
1494
+
1495
+ if (!attributeId) {
1496
+ continue;
1497
+ }
1498
+
1499
+ nextDrafts[attributeId] = {
1500
+ attribute_id: attributeId,
1501
+ data_type: String(attribute.data_type ?? nextDrafts[attributeId]?.data_type ?? 'text'),
1502
+ attribute_option_id:
1503
+ attribute.attribute_option_id === null ||
1504
+ attribute.attribute_option_id === undefined
1505
+ ? null
1506
+ : Number(attribute.attribute_option_id),
1507
+ value_text: String(attribute.value_text ?? ''),
1508
+ value_number:
1509
+ attribute.value_number === null || attribute.value_number === undefined
1510
+ ? null
1511
+ : Number(attribute.value_number),
1512
+ value_boolean:
1513
+ typeof attribute.value_boolean === 'boolean'
1514
+ ? attribute.value_boolean
1515
+ : null,
1516
+ value_unit: String(attribute.value_unit ?? ''),
1517
+ source_type: String(attribute.source_type ?? 'manual'),
1518
+ confidence_score:
1519
+ attribute.confidence_score === null ||
1520
+ attribute.confidence_score === undefined
1521
+ ? null
1522
+ : Number(attribute.confidence_score),
1523
+ is_verified: Boolean(attribute.is_verified ?? false),
1524
+ };
1525
+ }
1526
+
1527
+ setProductAttributeValues(nextDrafts);
1528
+ setProductAttributeErrors({});
1529
+ }
1530
+ };
1531
+
1532
+ return (
1533
+ <Sheet open={open} onOpenChange={onOpenChange}>
1534
1534
  <SheetContent className="w-full overflow-x-hidden overflow-y-auto sm:max-w-2xl">
1535
1535
  <SheetHeader>
1536
1536
  <SheetTitle>{submitLabel}</SheetTitle>
@@ -1543,30 +1543,30 @@ export function CatalogResourceFormSheet({
1543
1543
  Carregando dados do registro...
1544
1544
  </div>
1545
1545
  ) : (
1546
- <Form {...form}>
1547
- <form
1548
- id={`catalog-form-${resource}`}
1549
- className="min-w-0 space-y-6 px-4 pb-4"
1550
- onSubmit={form.handleSubmit(handleSubmit)}
1551
- >
1552
- <div className="flex justify-end">
1553
- <CatalogAiFormAssistDialog
1554
- resource={resource}
1555
- resourceConfig={resourceConfig}
1556
- disabled={form.formState.isSubmitting || isLoading}
1557
- getCurrentValues={() =>
1558
- buildCurrentFormPayload(resourceConfig, form.getValues())
1559
- }
1560
- getCurrentAttributeValues={() =>
1561
- buildCurrentProductAttributeValues(productAttributeValues)
1562
- }
1563
- onApply={handleApplyAiSuggestion}
1564
- />
1565
- </div>
1566
-
1567
- {resourceConfig.formSections.map((section) => (
1568
- <section
1569
- key={section.title.en}
1546
+ <Form {...form}>
1547
+ <form
1548
+ id={`catalog-form-${resource}`}
1549
+ className="min-w-0 space-y-6 px-4 pb-4"
1550
+ onSubmit={form.handleSubmit(handleSubmit)}
1551
+ >
1552
+ <div className="flex justify-end">
1553
+ <CatalogAiFormAssistDialog
1554
+ resource={resource}
1555
+ resourceConfig={resourceConfig}
1556
+ disabled={form.formState.isSubmitting || isLoading}
1557
+ getCurrentValues={() =>
1558
+ buildCurrentFormPayload(resourceConfig, form.getValues())
1559
+ }
1560
+ getCurrentAttributeValues={() =>
1561
+ buildCurrentProductAttributeValues(productAttributeValues)
1562
+ }
1563
+ onApply={handleApplyAiSuggestion}
1564
+ />
1565
+ </div>
1566
+
1567
+ {resourceConfig.formSections.map((section) => (
1568
+ <section
1569
+ key={section.title.en}
1570
1570
  className="min-w-0 max-w-full space-y-4"
1571
1571
  >
1572
1572
  <div>
@@ -1746,24 +1746,24 @@ export function CatalogResourceFormSheet({
1746
1746
  </div>
1747
1747
  ))}
1748
1748
  </div>
1749
- </section>
1750
- ))}
1751
-
1752
- {resource === 'products' ? (
1753
- <ProductAttributesSection
1754
- open={open}
1755
- recordId={recordId}
1756
- categoryId={selectedCategoryId}
1757
- initialCategoryId={initialCategoryId}
1758
- values={productAttributeValues}
1759
- onChange={setProductAttributeValues}
1760
- errors={productAttributeErrors}
1761
- onSchemaChange={setProductAttributeSchema}
1762
- />
1763
- ) : null}
1764
- </form>
1765
- </Form>
1766
- )}
1749
+ </section>
1750
+ ))}
1751
+
1752
+ {resource === 'products' ? (
1753
+ <ProductAttributesSection
1754
+ open={open}
1755
+ recordId={recordId}
1756
+ categoryId={selectedCategoryId}
1757
+ initialCategoryId={initialCategoryId}
1758
+ values={productAttributeValues}
1759
+ onChange={setProductAttributeValues}
1760
+ errors={productAttributeErrors}
1761
+ onSchemaChange={setProductAttributeSchema}
1762
+ />
1763
+ ) : null}
1764
+ </form>
1765
+ </Form>
1766
+ )}
1767
1767
 
1768
1768
  <SheetFooter className="border-t">
1769
1769
  <div className="w-full">