@hed-hog/catalog 0.0.293 → 0.0.294

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 (45) hide show
  1. package/README.md +409 -379
  2. package/dist/catalog-resource.config.d.ts.map +1 -1
  3. package/dist/catalog-resource.config.js +51 -24
  4. package/dist/catalog-resource.config.js.map +1 -1
  5. package/dist/catalog.controller.d.ts +420 -0
  6. package/dist/catalog.controller.d.ts.map +1 -1
  7. package/dist/catalog.controller.js +98 -0
  8. package/dist/catalog.controller.js.map +1 -1
  9. package/dist/catalog.module.d.ts.map +1 -1
  10. package/dist/catalog.module.js +5 -1
  11. package/dist/catalog.module.js.map +1 -1
  12. package/dist/catalog.service.d.ts +216 -1
  13. package/dist/catalog.service.d.ts.map +1 -1
  14. package/dist/catalog.service.js +1111 -5
  15. package/dist/catalog.service.js.map +1 -1
  16. package/hedhog/data/catalog_attribute.yaml +202 -0
  17. package/hedhog/data/catalog_attribute_option.yaml +109 -0
  18. package/hedhog/data/catalog_category.yaml +47 -0
  19. package/hedhog/data/catalog_category_attribute.yaml +209 -0
  20. package/hedhog/data/menu.yaml +133 -99
  21. package/hedhog/data/route.yaml +72 -8
  22. package/hedhog/frontend/app/[resource]/page.tsx.ejs +391 -33
  23. package/hedhog/frontend/app/_components/catalog-ai-form-assist-dialog.tsx.ejs +340 -0
  24. package/hedhog/frontend/app/_components/catalog-resource-form-sheet.tsx.ejs +907 -92
  25. package/hedhog/frontend/app/_lib/catalog-resources.tsx.ejs +929 -1161
  26. package/hedhog/frontend/messages/en.json +389 -299
  27. package/hedhog/frontend/messages/pt.json +389 -299
  28. package/hedhog/table/catalog_attribute.yaml +67 -52
  29. package/hedhog/table/catalog_attribute_option.yaml +40 -0
  30. package/hedhog/table/catalog_category.yaml +40 -0
  31. package/hedhog/table/catalog_category_attribute.yaml +37 -31
  32. package/hedhog/table/catalog_comparison.yaml +19 -22
  33. package/hedhog/table/catalog_product.yaml +30 -28
  34. package/hedhog/table/catalog_product_attribute_value.yaml +44 -31
  35. package/hedhog/table/catalog_product_category.yaml +13 -13
  36. package/hedhog/table/catalog_score_criterion.yaml +42 -25
  37. package/hedhog/table/catalog_seo_page_rule.yaml +10 -10
  38. package/hedhog/table/catalog_similarity_rule.yaml +33 -20
  39. package/hedhog/table/catalog_site.yaml +21 -13
  40. package/hedhog/table/catalog_site_category.yaml +12 -12
  41. package/package.json +6 -6
  42. package/src/catalog-resource.config.ts +132 -105
  43. package/src/catalog.controller.ts +91 -24
  44. package/src/catalog.module.ts +16 -12
  45. package/src/catalog.service.ts +1569 -56
@@ -1,7 +1,8 @@
1
1
  'use client';
2
2
 
3
- import { RichTextEditor } from '@/components/rich-text-editor';
4
- 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';
5
6
  import {
6
7
  Command,
7
8
  CommandEmpty,
@@ -51,19 +52,54 @@ import Image from 'next/image';
51
52
  import { useEffect, useMemo, useRef, useState } from 'react';
52
53
  import { useForm } from 'react-hook-form';
53
54
  import { toast } from 'sonner';
54
- import { z } from 'zod';
55
- import {
56
- catalogResourceMap,
57
- getCatalogLocalizedText,
58
- getCatalogRecordLabel,
55
+ import { z } from 'zod';
56
+ import { CatalogAiFormAssistDialog } from './catalog-ai-form-assist-dialog';
57
+ import {
58
+ catalogResourceMap,
59
+ getCatalogLocalizedText,
60
+ getCatalogRecordLabel,
59
61
  type CatalogFormFieldDefinition,
60
62
  type CatalogResourceDefinition,
61
63
  } from '../_lib/catalog-resources';
62
64
 
63
- type CatalogRecord = Record<string, unknown>;
64
- type CatalogFormValues = Record<string, unknown>;
65
- type RelationOption = { id: number; label: string };
66
- 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 = {
67
103
  open: boolean;
68
104
  onOpenChange: (open: boolean) => void;
69
105
  resource: string;
@@ -210,10 +246,10 @@ function serializeInitialValue(
210
246
  return rawValue === null || rawValue === undefined ? '' : String(rawValue);
211
247
  }
212
248
 
213
- function buildDefaultValues(
214
- resourceConfig: CatalogResourceDefinition,
215
- payload: CatalogRecord
216
- ) {
249
+ function buildDefaultValues(
250
+ resourceConfig: CatalogResourceDefinition,
251
+ payload: CatalogRecord
252
+ ) {
217
253
  const defaults: CatalogFormValues = {};
218
254
 
219
255
  for (const section of resourceConfig.formSections) {
@@ -222,8 +258,27 @@ function buildDefaultValues(
222
258
  }
223
259
  }
224
260
 
225
- return defaults;
226
- }
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
+ }
227
282
 
228
283
  function normalizeSubmitValue(
229
284
  field: CatalogFormFieldDefinition,
@@ -268,8 +323,227 @@ function normalizeSubmitValue(
268
323
  return String(rawValue || '').trim() || null;
269
324
  }
270
325
 
271
- return rawValue;
272
- }
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
+ }
273
547
 
274
548
  function extractRecordId(record: CatalogRecord) {
275
549
  return Number(record.id ?? record.category_id ?? record.content_id ?? 0);
@@ -505,7 +779,7 @@ function CatalogRelationField({
505
779
  );
506
780
  }
507
781
 
508
- function CatalogUploadField({
782
+ function CatalogUploadField({
509
783
  field,
510
784
  value,
511
785
  onChange,
@@ -658,10 +932,387 @@ function CatalogUploadField({
658
932
 
659
933
  {isUploading ? <Progress value={progress} /> : null}
660
934
  </div>
661
- );
662
- }
663
-
664
- 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({
665
1316
  open,
666
1317
  onOpenChange,
667
1318
  resource,
@@ -671,20 +1322,28 @@ export function CatalogResourceFormSheet({
671
1322
  recordId,
672
1323
  onSuccess,
673
1324
  }: FormSheetProps) {
674
- const { request, currentLocaleCode } = useApp();
675
- const t = useTranslations('catalog');
676
- const isEditing = Boolean(recordId);
677
- const formSchema = useMemo(
678
- () => buildFormSchema(resourceConfig),
679
- [resourceConfig]
680
- );
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
+ );
681
1340
 
682
1341
  const form = useForm<CatalogFormValues>({
683
1342
  resolver: zodResolver(formSchema),
684
1343
  defaultValues: buildDefaultValues(resourceConfig, resourceConfig.template),
685
1344
  });
686
1345
 
687
- const { data: currentRecord, isLoading } = useQuery<CatalogRecord>({
1346
+ const { data: currentRecord, isLoading } = useQuery<CatalogRecord>({
688
1347
  queryKey: ['catalog-record-details', resource, recordId],
689
1348
  queryFn: async () => {
690
1349
  const response = await request({
@@ -693,57 +1352,185 @@ export function CatalogResourceFormSheet({
693
1352
  });
694
1353
 
695
1354
  return response.data as CatalogRecord;
696
- },
697
- enabled: open && Boolean(recordId),
698
- });
699
-
700
- useEffect(() => {
701
- if (!open) {
702
- return;
703
- }
704
-
705
- const payload = currentRecord || resourceConfig.template;
706
- form.reset(buildDefaultValues(resourceConfig, payload));
707
- }, [currentRecord, form, open, resourceConfig]);
708
-
709
- const submitLabel = isEditing
710
- ? getCatalogLocalizedText(resourceConfig.editActionLabel, currentLocaleCode)
711
- : getCatalogLocalizedText(
712
- resourceConfig.createActionLabel,
713
- currentLocaleCode
714
- );
715
-
716
- const handleSubmit = async (values: CatalogFormValues) => {
717
- try {
718
- const payload: CatalogRecord = {};
719
-
720
- for (const section of resourceConfig.formSections) {
721
- for (const field of section.fields) {
722
- payload[field.key] = normalizeSubmitValue(field, values[field.key]);
723
- }
724
- }
725
-
726
- const response = await request({
727
- url: isEditing
728
- ? `/catalog/${resource}/${recordId}`
729
- : `/catalog/${resource}`,
730
- method: isEditing ? 'PATCH' : 'POST',
731
- data: payload,
732
- });
733
-
734
- const savedRecord = (response.data || payload) as CatalogRecord;
735
- toast.success(t('toasts.saveSuccess', { resource: resourceTitle }));
736
- onOpenChange(false);
737
- 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);
738
1458
  } catch (error) {
739
1459
  toast.error(
740
1460
  error instanceof Error ? error.message : t('toasts.saveError')
741
1461
  );
742
1462
  }
743
- };
744
-
745
- return (
746
- <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}>
747
1534
  <SheetContent className="w-full overflow-x-hidden overflow-y-auto sm:max-w-2xl">
748
1535
  <SheetHeader>
749
1536
  <SheetTitle>{submitLabel}</SheetTitle>
@@ -756,15 +1543,30 @@ export function CatalogResourceFormSheet({
756
1543
  Carregando dados do registro...
757
1544
  </div>
758
1545
  ) : (
759
- <Form {...form}>
760
- <form
761
- id={`catalog-form-${resource}`}
762
- className="min-w-0 space-y-6 px-4 pb-4"
763
- onSubmit={form.handleSubmit(handleSubmit)}
764
- >
765
- {resourceConfig.formSections.map((section) => (
766
- <section
767
- 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}
768
1570
  className="min-w-0 max-w-full space-y-4"
769
1571
  >
770
1572
  <div>
@@ -944,11 +1746,24 @@ export function CatalogResourceFormSheet({
944
1746
  </div>
945
1747
  ))}
946
1748
  </div>
947
- </section>
948
- ))}
949
- </form>
950
- </Form>
951
- )}
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
+ )}
952
1767
 
953
1768
  <SheetFooter className="border-t">
954
1769
  <div className="w-full">