contentoh-components-library 21.6.54 → 21.6.56

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 (61) hide show
  1. package/.claude/launch.json +14 -0
  2. package/.env.test +1 -0
  3. package/dist/components/atoms/IconFile/index.js +85 -2
  4. package/dist/components/atoms/IconFile/styles.js +1 -1
  5. package/dist/components/atoms/ProductImage/ProductImage.stories.js +70 -2
  6. package/dist/components/atoms/ProductImage/index.js +100 -27
  7. package/dist/components/atoms/ProductImage/styles.js +3 -1
  8. package/dist/components/molecules/EditionActiveImage/index.js +4 -2
  9. package/dist/components/molecules/GalleryElement/index.js +13 -4
  10. package/dist/components/molecules/GalleryHeader/index.js +96 -24
  11. package/dist/components/molecules/GalleryHeader/styles.js +4 -1
  12. package/dist/components/molecules/ImageSelector/index.js +1 -0
  13. package/dist/components/organisms/ImagePreviewer/index.js +1 -0
  14. package/dist/components/organisms/ProductImageModal/index.js +3 -2
  15. package/dist/components/pages/RetailerProductEdition/context/provider-product-edition.context.js +330 -15
  16. package/dist/components/pages/RetailerProductEdition/context/provider-product-edition.reducer.js +12 -2
  17. package/dist/components/pages/RetailerProductEdition/index.js +628 -428
  18. package/dist/components/pages/RetailerProductEdition/stories/ImageEditor.stories.js +1 -0
  19. package/dist/components/pages/RetailerProductEdition/utils.js +48 -1
  20. package/dist/global-files/data.js +16 -3
  21. package/dist/index.js +142 -90
  22. package/docs/activos-digitales.md +273 -0
  23. package/package.json +1 -1
  24. package/src/components/atoms/AssetPreview/AssetPreview.stories.js +78 -0
  25. package/src/components/atoms/AssetPreview/index.js +119 -0
  26. package/src/components/atoms/AssetPreview/styles.js +190 -0
  27. package/src/components/atoms/IconFile/index.js +28 -1
  28. package/src/components/atoms/IconFile/styles.js +8 -0
  29. package/src/components/atoms/ProductImage/ProductImage.stories.js +38 -0
  30. package/src/components/atoms/ProductImage/index.js +95 -20
  31. package/src/components/atoms/ProductImage/styles.js +92 -7
  32. package/src/components/molecules/EditionActiveImage/index.js +2 -2
  33. package/src/components/molecules/GalleryAssetElement/GalleryAssetElement.stories.js +102 -0
  34. package/src/components/molecules/GalleryAssetElement/index.js +162 -0
  35. package/src/components/molecules/GalleryAssetElement/styles.js +159 -0
  36. package/src/components/molecules/GalleryElement/index.js +9 -0
  37. package/src/components/molecules/GalleryHeader/index.js +65 -13
  38. package/src/components/molecules/GalleryHeader/styles.js +2 -1
  39. package/src/components/molecules/GalleryTrayTabs/GalleryTrayTabs.stories.js +32 -0
  40. package/src/components/molecules/GalleryTrayTabs/index.js +41 -0
  41. package/src/components/molecules/GalleryTrayTabs/styles.js +52 -0
  42. package/src/components/molecules/ImageSelector/index.js +1 -0
  43. package/src/components/organisms/DigitalAssetsTray/DigitalAssetsTray.stories.js +101 -0
  44. package/src/components/organisms/DigitalAssetsTray/index.js +189 -0
  45. package/src/components/organisms/DigitalAssetsTray/styles.js +183 -0
  46. package/src/components/organisms/ImagePreviewer/index.js +1 -0
  47. package/src/components/organisms/ProductImageModal/index.js +1 -0
  48. package/src/components/pages/RetailerProductEdition/context/provider-product-edition.context.jsx +201 -0
  49. package/src/components/pages/RetailerProductEdition/context/provider-product-edition.reducer.js +12 -0
  50. package/src/components/pages/RetailerProductEdition/index.js +379 -142
  51. package/src/components/pages/RetailerProductEdition/stories/ImageEditor.stories.js +1 -0
  52. package/src/components/pages/RetailerProductEdition/utils.js +34 -0
  53. package/src/global-files/data.js +17 -2
  54. package/src/global-files/digital-assets/assetTypes.js +419 -0
  55. package/src/global-files/digital-assets/assets.actions.js +50 -0
  56. package/src/global-files/digital-assets/assets.adapter.js +229 -0
  57. package/src/global-files/digital-assets/assets.mock.js +122 -0
  58. package/src/global-files/digital-assets/assets.reducer.js +197 -0
  59. package/src/global-files/digital-assets/uploadAssets.js +109 -0
  60. package/src/global-files/digital-assets/useDigitalAssets.js +329 -0
  61. package/src/index.js +4 -0
@@ -1,4 +1,4 @@
1
- import { useEffect, useState } from "react";
1
+ import { useEffect, useRef, useState } from "react";
2
2
 
3
3
  import axios from "axios";
4
4
  import { useDropzone } from "react-dropzone";
@@ -17,7 +17,7 @@ import {
17
17
  createMessage,
18
18
  sendMessage,
19
19
  translateConcept,
20
- getStatusArrayByRole,
20
+ getStatusArrayForUser,
21
21
  getConceptsByRole,
22
22
  buildCollaboratorAssignations,
23
23
  calculateRequiredNull,
@@ -30,6 +30,8 @@ import {
30
30
  getServicesData,
31
31
  } from "../../../global-files/data";
32
32
  import { useCloseModal } from "../../../global-files/customHooks";
33
+ import { useDigitalAssets } from "../../../global-files/digital-assets/useDigitalAssets";
34
+ import { countUnsavedAssets } from "../../../global-files/digital-assets/assets.reducer";
33
35
 
34
36
  import { Button } from "../../atoms/GeneralButton";
35
37
  import { GenericModal } from "../../atoms/GenericModal";
@@ -39,12 +41,14 @@ import { ButtonV2 } from "../../atoms/ButtonV2";
39
41
 
40
42
  import { GalleryElement } from "../../molecules/GalleryElement";
41
43
  import { GalleryHeader } from "../../molecules/GalleryHeader";
44
+ import { GalleryTrayTabs, TRAYS } from "../../molecules/GalleryTrayTabs";
42
45
  import { HeaderTop } from "../../molecules/HeaderTop";
43
46
  import { TagAndInput } from "../../molecules/TagAndInput/index";
44
47
 
45
48
  import { FullProductNameHeader } from "../../organisms/FullProductNameHeader";
46
49
  import { FullTabsMenu } from "../../organisms/FullTabsMenu";
47
50
  import { ImageDataTable } from "../../organisms/ImageDataTable";
51
+ import { DigitalAssetsTray } from "../../organisms/DigitalAssetsTray";
48
52
  import { ImagePreviewer } from "../../organisms/ImagePreviewer";
49
53
  import { InputGroup } from "../../organisms/InputGroup";
50
54
  import { ProductImageModal } from "../../organisms/ProductImageModal";
@@ -101,62 +105,19 @@ function obtenerDuracionVideoBase64(base64Data) {
101
105
  });
102
106
  }
103
107
 
104
- function convertirVideoABase64(url) {
105
- return new Promise((resolve, reject) => {
106
- const xhr = new XMLHttpRequest();
107
- xhr.open("GET", url, true);
108
- xhr.responseType = "blob";
109
- xhr.onload = function () {
110
- if (xhr.status === 200) {
111
- const blob = xhr.response;
112
- const reader = new FileReader();
113
- reader.onloadend = function () {
114
- resolve(reader.result);
115
- };
116
- reader.readAsDataURL(blob);
117
- } else {
118
- reject(new Error("Error al cargar el video"));
119
- }
120
- };
121
- xhr.onerror = function () {
122
- reject(new Error("Error de red al cargar el video"));
123
- };
124
- xhr.send();
125
- });
126
- }
127
-
128
- // Para videos ya guardados, `getImage` deja en `src` una URL del
129
- // redimensionador de imágenes que no sirve para mp4. Descargamos el video
130
- // desde S3 (srcDB) y generamos un thumbnail JPEG para mostrarlo en la galería.
131
- async function regenerateVideoThumbnails(values) {
132
- if (!Array.isArray(values)) return values;
133
- return Promise.all(
134
- values.map(async (value) => {
135
- if (value?.ext !== "mp4" || !value?.srcDB) return value;
136
- try {
137
- const base64 = await convertirVideoABase64(
138
- "https://" +
139
- process.env.REACT_APP_IMAGES_BUCKET +
140
- ".s3.amazonaws.com/" +
141
- value.srcDB,
142
- );
143
- const videoSrc = "data:video/mp4;base64," + base64.split(",")[1];
144
- const thumbnail = await generateThumbnail(videoSrc, 2);
145
- return { ...value, src: thumbnail };
146
- } catch (error) {
147
- console.error("Error al generar thumbnail del video:", error);
148
- return value;
149
- }
150
- }),
151
- );
152
- }
153
-
154
108
  const RetailerProductEditionView = ({
155
109
  tabsSections,
156
110
  productSelected = {},
157
111
  user = {},
158
112
  token,
159
113
  location,
114
+ /*
115
+ Activos digitales (360, video, gif, pdf). Apagado por omisión: la interfaz
116
+ ya está, pero el backend no existe todavía, así que la bandeja solo puede
117
+ mostrar lo que el usuario acaba de soltar y nada se guarda. La aplicación
118
+ que consuma la librería lo enciende cuando el backend esté listo.
119
+ */
120
+ enableDigitalAssets = false,
160
121
  }) => {
161
122
  const {
162
123
  state,
@@ -165,6 +126,8 @@ const RetailerProductEditionView = ({
165
126
  saveDescriptions,
166
127
  updateImages,
167
128
  deleteImages,
129
+ saveAssets,
130
+ registerUploadedAssets,
168
131
  handleOnDownloadImages,
169
132
  createComment,
170
133
  } = useProviderProductEdition();
@@ -304,6 +267,54 @@ const RetailerProductEditionView = ({
304
267
 
305
268
  const [openChangeStatusModal, setOpenChangeStatusModal] = useState(false);
306
269
 
270
+ /*
271
+ Bandeja de activos digitales. La lista vive en el reducer de la ficha
272
+ (`state.assets_values`), igual que las imágenes, para que el guardado del
273
+ contexto pueda leerla. El hook solo aporta validación y ciclo de vida.
274
+
275
+ Sigue vacía porque todavía no hay de dónde leerla: cuando exista el endpoint
276
+ se llama a `replaceAll` desde `loadData` y el resto de la pantalla no cambia.
277
+ */
278
+ const [assetNotices, setAssetNotices] = useState([]);
279
+ const digitalAssets = useDigitalAssets({
280
+ assets: state.assets_values,
281
+ dispatch,
282
+ notices: assetNotices,
283
+ setNotices: setAssetNotices,
284
+ // Con los dos, la subida a S3 arranca sola al soltar los archivos. Sin
285
+ // ellos —en Storybook, por ejemplo— la bandeja funciona igual pero no sube.
286
+ articleId: state.product?.id_article ?? null,
287
+ token,
288
+ // Al terminar la subida el activo queda registrado solo, sin esperar al
289
+ // botón de guardar: con la barra llena, nadie supone que falta un paso.
290
+ onUploaded: (uploaded) => registerUploadedAssets(token, uploaded),
291
+ });
292
+ const [activeTray, setActiveTray] = useState(TRAYS.IMAGES);
293
+ const currentTray = enableDigitalAssets ? activeTray : TRAYS.IMAGES;
294
+
295
+ /*
296
+ De qué artículo se cargaron ya los activos.
297
+
298
+ `loadData` se vuelve a ejecutar cada vez que cambia la cadena activa, pero los
299
+ activos son generales: no dependen de la cadena ni de la versión, así que la
300
+ respuesta sería idéntica. Volver a cargarlos borraría los archivos que el
301
+ usuario acaba de soltar y todavía no guarda —sin aviso y sin forma de
302
+ recuperarlos—. Por eso se cargan una sola vez por artículo.
303
+ */
304
+ const assetsLoadedFor = useRef(null);
305
+
306
+ const handleOnSetSelectedAssets = (assets) =>
307
+ dispatch({
308
+ type: "SET_SELECTED_ASSETS",
309
+ payload: assets,
310
+ });
311
+
312
+ const handleOnSetAllAssetsChecked = (isChecked) =>
313
+ dispatch({
314
+ type: "TOGGLE_CHECK_ALL_ASSETS",
315
+ payload: isChecked,
316
+ });
317
+
307
318
  const thumbs = () => {
308
319
  const imageInputs = state.images_values?.inputs?.map((e) => ({
309
320
  value: e?.id,
@@ -346,6 +357,7 @@ const RetailerProductEditionView = ({
346
357
  updateApprovedInputsImages={(item) =>
347
358
  console.log("updateApprovedInputsImages", item)
348
359
  }
360
+ onSelect={() => handleOnChangeCurrentImage(index)}
349
361
  />
350
362
  ));
351
363
  };
@@ -374,8 +386,10 @@ const RetailerProductEditionView = ({
374
386
  ),
375
387
  );
376
388
 
377
- // Configurar statusArray según el rol del usuario
378
- setStatusArray(getStatusArrayByRole(user.id_role));
389
+ // Los estatus evaluables salen del rol UNIDO a la asignación. Solo por
390
+ // rol, quien produce y además audita no tenía 'AC' en la lista y el
391
+ // envío se quedaba sin nada que mandar.
392
+ setStatusArray(getStatusArrayForUser(user, productNormalized));
379
393
 
380
394
  // Cargar usuarios y asignaciones
381
395
  const users = await fetchUsers(token);
@@ -612,12 +626,6 @@ const RetailerProductEditionView = ({
612
626
  );
613
627
  });
614
628
 
615
- // Regenerar thumbnails de videos guardados para que se muestren.
616
- const orderedValuesWithThumbs =
617
- await regenerateVideoThumbnails(orderedValues);
618
- const filteredValuesWithThumbs =
619
- await regenerateVideoThumbnails(filteredValues);
620
-
621
629
  // Dispatch consolidado
622
630
  dispatch({
623
631
  type: "SET_SERVICES",
@@ -626,7 +634,7 @@ const RetailerProductEditionView = ({
626
634
  descriptions: services[1],
627
635
  images: {
628
636
  ...services[2],
629
- values: orderedValuesWithThumbs,
637
+ values: orderedValues,
630
638
  },
631
639
  },
632
640
  });
@@ -643,7 +651,12 @@ const RetailerProductEditionView = ({
643
651
  // Setear inputs del retailer activo directamente (evita cascada)
644
652
  dispatch({
645
653
  type: "SET_DATASHEETS_INPUTS",
646
- payload: [datasheetsActiveRetailer, services[0].inputs],
654
+ // `services[0]?` por lo mismo que el fallback de arriba: la lectura de
655
+ // fichas puede volver sin datos para esa cadena y categoría. Arriba ya
656
+ // se contemplaba; aquí no, y como este dispatch va ANTES de
657
+ // SET_IMAGES_VALUES, reventar aquí dejaba la ficha cargando para
658
+ // siempre — con las imágenes ya traídas y sin pintar.
659
+ payload: [datasheetsActiveRetailer, services[0]?.inputs ?? {}],
647
660
  });
648
661
 
649
662
  dispatch({
@@ -655,10 +668,23 @@ const RetailerProductEditionView = ({
655
668
  type: "SET_IMAGES_VALUES",
656
669
  payload: {
657
670
  ...services[2],
658
- values: filteredValuesWithThumbs,
671
+ values: filteredValues,
659
672
  },
660
673
  });
661
674
 
675
+ /*
676
+ Activos digitales. `services[2].assets` todavía no existe en la
677
+ respuesta del backend: hasta que exista, esto no hace nada, y el día que
678
+ llegue la bandeja se llena sola sin tocar nada más.
679
+ */
680
+ if (
681
+ Array.isArray(services[2].assets) &&
682
+ assetsLoadedFor.current !== id_article
683
+ ) {
684
+ assetsLoadedFor.current = id_article;
685
+ digitalAssets.replaceAll(services[2].assets);
686
+ }
687
+
662
688
  // Calcular campos requeridos sin valor
663
689
  dispatch({
664
690
  type: "SET_MISSING_REQUIRED_FIELDS",
@@ -808,7 +834,8 @@ const RetailerProductEditionView = ({
808
834
  const canEvaluateAsAuditor =
809
835
  state.services_data.every((srv) =>
810
836
  ["AC", "RP", "RCA"].includes(srv?.status?.replace(/.*\//, "")),
811
- ) && ([1, 6].includes(user.id_role) || isAuditorAssigned());
837
+ ) &&
838
+ ([1, 6].includes(user.id_role) || isAuditorAssigned());
812
839
 
813
840
  const canEvaluateAC =
814
841
  services.length > 0 &&
@@ -834,7 +861,29 @@ const RetailerProductEditionView = ({
834
861
  return currentStatus;
835
862
  };
836
863
 
864
+ /*
865
+ Guarda los activos y recarga si algo se escribió.
866
+
867
+ La recarga no es opcional: un activo recién creado sigue en memoria con su
868
+ id temporal y sin el id de la base. Sin volver a leer, el siguiente guardado
869
+ lo mandaría otra vez como nuevo y se duplicaría. Poner el ref en `null` es
870
+ lo que permite que `loadData` lo vuelva a traer —normalmente los activos se
871
+ cargan una sola vez por artículo—.
872
+ */
873
+ const handleOnSaveAssets = async () => {
874
+ const guardado = await saveAssets(token);
875
+ if (!guardado) return;
876
+
877
+ assetsLoadedFor.current = null;
878
+ await loadData(false);
879
+ };
880
+
837
881
  const handleOnClickSaveImages = async () => {
882
+ if (currentTray === TRAYS.ASSETS) {
883
+ await handleOnSaveAssets();
884
+ return;
885
+ }
886
+
838
887
  if (state.product?.services?.images === 1) {
839
888
  /**
840
889
  * `updateImages` y NO `saveImageAttrs`.
@@ -872,7 +921,7 @@ const RetailerProductEditionView = ({
872
921
  const res = await axios.post(
873
922
  `${process.env.REACT_APP_VARIANTS_ENDPOINT}/family`,
874
923
  { articleId: state.product?.id_article },
875
- { headers: { Authorization: token } }
924
+ { headers: { Authorization: token } },
876
925
  );
877
926
  const data = readPayload(res);
878
927
  setFamily(data);
@@ -962,9 +1011,9 @@ const RetailerProductEditionView = ({
962
1011
  result.mode === "self"
963
1012
  ? { ...state.product, parentArticleId: null, variantsCount: 0 }
964
1013
  : {
965
- ...state.product,
966
- variantsCount: Math.max((state.product?.variantsCount ?? 1) - 1, 0),
967
- };
1014
+ ...state.product,
1015
+ variantsCount: Math.max((state.product?.variantsCount ?? 1) - 1, 0),
1016
+ };
968
1017
 
969
1018
  dispatch({ type: "SET_PRODUCT", payload: updatedProduct });
970
1019
 
@@ -973,7 +1022,7 @@ const RetailerProductEditionView = ({
973
1022
  // aplanada de esta pantalla lo dejaría mutilado en el siguiente F5.
974
1023
  try {
975
1024
  const guardado = JSON.parse(
976
- sessionStorage.getItem("productSelected") ?? "null"
1025
+ sessionStorage.getItem("productSelected") ?? "null",
977
1026
  );
978
1027
 
979
1028
  if (guardado) {
@@ -983,7 +1032,7 @@ const RetailerProductEditionView = ({
983
1032
  ...guardado,
984
1033
  variantsCount: updatedProduct.variantsCount,
985
1034
  parentArticleId: updatedProduct.parentArticleId ?? null,
986
- })
1035
+ }),
987
1036
  );
988
1037
  }
989
1038
  } catch (error) {
@@ -1024,7 +1073,7 @@ const RetailerProductEditionView = ({
1024
1073
  const res = await axios.post(
1025
1074
  `${process.env.REACT_APP_VARIANTS_ENDPOINT}/group`,
1026
1075
  { articleId: state.product?.id_article },
1027
- { headers: { Authorization: token } }
1076
+ { headers: { Authorization: token } },
1028
1077
  );
1029
1078
  const data = readPayload(res);
1030
1079
  setVariantGroup(data);
@@ -1047,7 +1096,7 @@ const RetailerProductEditionView = ({
1047
1096
  const res = await axios.post(
1048
1097
  `${process.env.REACT_APP_VARIANTS_ENDPOINT}/leave`,
1049
1098
  { articleId: state.product?.id_article },
1050
- { headers: { Authorization: token } }
1099
+ { headers: { Authorization: token } },
1051
1100
  );
1052
1101
  setVariantGroup(readPayload(res));
1053
1102
  setShowGroup(false);
@@ -1082,7 +1131,7 @@ const RetailerProductEditionView = ({
1082
1131
  {
1083
1132
  params: { filterByIdArticle: articleId, includeFilters: false },
1084
1133
  headers: { Authorization: token },
1085
- }
1134
+ },
1086
1135
  );
1087
1136
 
1088
1137
  // read-order-services responde { total, data: [...] }. La vista trae una
@@ -1090,12 +1139,12 @@ const RetailerProductEditionView = ({
1090
1139
  // ordenadas por order_id DESC y la primera es la orden más reciente.
1091
1140
  const { data } = readPayload(res) ?? {};
1092
1141
  const encontrado = (data ?? []).find(
1093
- (item) => Number(item?.article?.id_article) === Number(articleId)
1142
+ (item) => Number(item?.article?.id_article) === Number(articleId),
1094
1143
  );
1095
1144
 
1096
1145
  if (!encontrado) {
1097
1146
  setAvisoFamilia(
1098
- "Ese producto no está en ninguna orden de trabajo, así que no se puede abrir desde aquí."
1147
+ "Ese producto no está en ninguna orden de trabajo, así que no se puede abrir desde aquí.",
1099
1148
  );
1100
1149
  return;
1101
1150
  }
@@ -1108,7 +1157,7 @@ const RetailerProductEditionView = ({
1108
1157
  ArticleId: encontrado.article.id_article,
1109
1158
  categoryId: encontrado.article.id_article,
1110
1159
  product: [encontrado],
1111
- })
1160
+ }),
1112
1161
  );
1113
1162
 
1114
1163
  window.location.assign("/EditProducts");
@@ -1133,7 +1182,8 @@ const RetailerProductEditionView = ({
1133
1182
  ? {
1134
1183
  ...state.product,
1135
1184
  variantsCount:
1136
- (state.product?.variantsCount ?? 0) + (result.linked ?? []).length,
1185
+ (state.product?.variantsCount ?? 0) +
1186
+ (result.linked ?? []).length,
1137
1187
  }
1138
1188
  : {
1139
1189
  ...state.product,
@@ -1157,7 +1207,7 @@ const RetailerProductEditionView = ({
1157
1207
  */
1158
1208
  try {
1159
1209
  const guardado = JSON.parse(
1160
- sessionStorage.getItem("productSelected") ?? "null"
1210
+ sessionStorage.getItem("productSelected") ?? "null",
1161
1211
  );
1162
1212
 
1163
1213
  if (guardado) {
@@ -1167,7 +1217,7 @@ const RetailerProductEditionView = ({
1167
1217
  ...guardado,
1168
1218
  variantsCount: updatedProduct.variantsCount,
1169
1219
  parentArticleId: updatedProduct.parentArticleId ?? null,
1170
- })
1220
+ }),
1171
1221
  );
1172
1222
  }
1173
1223
  } catch (error) {
@@ -1209,7 +1259,10 @@ const RetailerProductEditionView = ({
1209
1259
  `rel_article_description` y la ficha técnica en `article_data`: el endpoint
1210
1260
  recibe una lista o la otra.
1211
1261
  */
1212
- const handleOnRestoreInheritance = async (attributeId, kind = "attribute") => {
1262
+ const handleOnRestoreInheritance = async (
1263
+ attributeId,
1264
+ kind = "attribute",
1265
+ ) => {
1213
1266
  if (!attributeId || !state.product?.id_article) return;
1214
1267
 
1215
1268
  const esDescripcion = kind === "description";
@@ -1223,7 +1276,7 @@ const RetailerProductEditionView = ({
1223
1276
  ? { descriptions: [Number(attributeId)] }
1224
1277
  : { attributes: [Number(attributeId)] }),
1225
1278
  },
1226
- { headers: { Authorization: token } }
1279
+ { headers: { Authorization: token } },
1227
1280
  );
1228
1281
 
1229
1282
  const data = readPayload(res);
@@ -1426,7 +1479,7 @@ const RetailerProductEditionView = ({
1426
1479
  puede copiar igual. Si no se miraran, guardar no ofrecería nada.
1427
1480
  */
1428
1481
  const companeras = (variantGroup?.members ?? []).filter(
1429
- (m) => !m.isCurrent
1482
+ (m) => !m.isCurrent,
1430
1483
  );
1431
1484
 
1432
1485
  // Un hijo tampoco puede estar agrupado desde 2026-09-25, así que para
@@ -1440,6 +1493,16 @@ const RetailerProductEditionView = ({
1440
1493
  break;
1441
1494
  }
1442
1495
  case "Imágenes":
1496
+ /*
1497
+ Se guarda la bandeja que el usuario tiene a la vista. Guardar las dos
1498
+ de golpe haría que, estando en activos, `updateImages` enviara también
1499
+ los cambios de imágenes que el usuario quizá no dio por terminados —y
1500
+ al revés—. El botón guarda lo que se está viendo.
1501
+ */
1502
+ if (currentTray === TRAYS.ASSETS) {
1503
+ await handleOnSaveAssets();
1504
+ return;
1505
+ }
1443
1506
  await updateImages(token);
1444
1507
  break;
1445
1508
  default:
@@ -1463,7 +1526,98 @@ const RetailerProductEditionView = ({
1463
1526
  });
1464
1527
  };
1465
1528
 
1529
+ /*
1530
+ Descarga de activos: los seleccionados, o todos si no hay selección — el
1531
+ mismo criterio que usa la descarga de imágenes.
1532
+
1533
+ Solo se descarga lo que ya está en S3. Un activo recién soltado vive como
1534
+ referencia al archivo del disco del usuario: "descargarlo" le devolvería el
1535
+ archivo que él mismo acaba de elegir, lo cual no tiene sentido.
1536
+ */
1537
+ const handleOnDownloadAssets = () => {
1538
+ const seleccion = state.selected_assets.length
1539
+ ? state.selected_assets
1540
+ : state.assets_values;
1541
+
1542
+ const descargables = seleccion.filter((asset) => asset.id && asset.url);
1543
+
1544
+ if (descargables.length === 0) {
1545
+ dispatch({
1546
+ type: "SET_MODAL",
1547
+ payload: {
1548
+ show: true,
1549
+ title: "Nada que descargar",
1550
+ message:
1551
+ "Los activos seleccionados todavía no se han guardado. Guárdalos primero.",
1552
+ image: warningIcon,
1553
+ },
1554
+ });
1555
+ return;
1556
+ }
1557
+
1558
+ descargables.forEach((asset) => saveAs(asset.url, asset.name));
1559
+ };
1560
+
1561
+ /*
1562
+ Borrado en lote de activos.
1563
+
1564
+ A diferencia de las imágenes, que se borran contra el backend en el momento,
1565
+ los activos se quitan de la bandeja y su baja viaja en el mismo PUT del
1566
+ guardado (`deleteAssets`). Es lo que permite al usuario deshacerse de un
1567
+ borrado por error: basta con no guardar. El mensaje lo dice, para que nadie
1568
+ se quede pensando que ya se aplicó.
1569
+ */
1570
+ const handleOnAskToDeleteAssets = () => {
1571
+ if (state.selected_assets.length === 0) {
1572
+ dispatch({
1573
+ type: "SET_MODAL",
1574
+ payload: {
1575
+ show: true,
1576
+ title: "Eliminar activos digitales",
1577
+ message: "No has seleccionado ningún activo para eliminar.",
1578
+ image: warningIcon,
1579
+ },
1580
+ });
1581
+ return;
1582
+ }
1583
+
1584
+ const seleccionados = state.selected_assets;
1585
+
1586
+ dispatch({
1587
+ type: "SET_MODAL",
1588
+ payload: {
1589
+ show: true,
1590
+ title: "Eliminar activos digitales",
1591
+ message: `Se quitarán ${seleccionados.length} activo(s) de la bandeja. El borrado se aplica al guardar.`,
1592
+ image: warningIcon,
1593
+ buttons: [
1594
+ {
1595
+ text: "Cancelar",
1596
+ buttonType: "general-white-button",
1597
+ action: () =>
1598
+ dispatch({ type: "SET_MODAL", payload: { show: false } }),
1599
+ },
1600
+ {
1601
+ text: "Eliminar",
1602
+ buttonType: "general-button-default",
1603
+ action: () => {
1604
+ dispatch({ type: "SET_MODAL", payload: { show: false } });
1605
+ seleccionados.forEach((asset) =>
1606
+ digitalAssets.removeAsset(asset),
1607
+ );
1608
+ },
1609
+ },
1610
+ ],
1611
+ },
1612
+ });
1613
+ };
1614
+
1466
1615
  const handleOnAskToDeleteImages = () => {
1616
+ if (currentTray === TRAYS.ASSETS) {
1617
+ handleOnAskToDeleteAssets();
1618
+ return;
1619
+ }
1620
+
1467
1621
  if (state.selected_images.length === 0) {
1468
1622
  dispatch({
1469
1623
  type: "SET_MODAL",
@@ -1769,6 +1923,24 @@ const RetailerProductEditionView = ({
1769
1923
 
1770
1924
  await Promise.all(evaluationArray);
1771
1925
 
1926
+ // Sin una sola petición no hubo evaluación. Seguir de largo es lo que
1927
+ // pintaba el estatus nuevo y anunciaba éxito mientras la base no se
1928
+ // enteraba: el cambio se veía en pantalla y no existía. Un no-op tiene
1929
+ // que decirlo, no fingir que funcionó.
1930
+ if (evaluationArray.length === 0) {
1931
+ dispatch({
1932
+ type: "SET_MODAL",
1933
+ payload: {
1934
+ show: true,
1935
+ title: "Error",
1936
+ message:
1937
+ "No hay servicios que puedas evaluar en el estatus actual.",
1938
+ image: errorIcon,
1939
+ },
1940
+ });
1941
+ return;
1942
+ }
1943
+
1772
1944
  const status = `${result}A`;
1773
1945
 
1774
1946
  // Actualizar el statusByRetailer para todos los retailers y conceptos
@@ -2178,7 +2350,25 @@ const RetailerProductEditionView = ({
2178
2350
  </Container>
2179
2351
  );
2180
2352
 
2181
- if (state.loading || !state.services || !state.product)
2353
+ /*
2354
+ `images_values` entra al guard junto a `services` y `product`.
2355
+
2356
+ React 17 no agrupa los dispatch que van después de un `await`: cada uno
2357
+ re-renderiza al instante. En `loadData` el orden es SET_SERVICES →
2358
+ …_INPUTS → SET_IMAGES_VALUES, así que había renders intermedios con
2359
+ `services` ya puesto —el guard los dejaba pasar— y el resto todavía en null.
2360
+ El cuerpo de abajo lee `datasheets_inputs[1]`, `descriptions_inputs[0]` y
2361
+ `images_values.values` sin protección, y tumbaba la pantalla entera.
2362
+
2363
+ Se comprueba `images_values` y no cada uno: es el ÚLTIMO que `loadData`
2364
+ despacha, así que si ya está, los anteriores también.
2365
+ */
2366
+ if (
2367
+ state.loading ||
2368
+ !state.services ||
2369
+ !state.product ||
2370
+ !state.images_values
2371
+ )
2182
2372
  return (
2183
2373
  <>
2184
2374
  <ProductEditionSkeleton />
@@ -2203,7 +2393,7 @@ const RetailerProductEditionView = ({
2203
2393
  : (family?.variants ?? []).filter((v) => !v.isCurrent)
2204
2394
  }
2205
2395
  groupMembers={(variantGroup?.members ?? []).filter(
2206
- (m) => !m.isCurrent
2396
+ (m) => !m.isCurrent,
2207
2397
  )}
2208
2398
  groupCode={variantGroup?.group ?? null}
2209
2399
  saving={state.saving}
@@ -2440,8 +2630,18 @@ const RetailerProductEditionView = ({
2440
2630
  <div className="data-container">
2441
2631
  <div className="image-data-panel">
2442
2632
  <ImagePreviewer
2633
+ /*
2634
+ `images_values` puede ser null en pleno render.
2635
+
2636
+ React 17 no agrupa los dispatch que van después de un `await`:
2637
+ cada uno re-renderiza al instante. En `loadData`, `SET_SERVICES`
2638
+ se despacha antes que `SET_IMAGES_VALUES`, así que hay un render
2639
+ en el que `services` ya existe —y por eso el guard de arriba deja
2640
+ pasar— pero las imágenes todavía no. Sin las interrogaciones,
2641
+ ese render intermedio tumba la pantalla entera.
2642
+ */
2443
2643
  activeImage={
2444
- state.images_values.values[state.current_image] ?? {}
2644
+ state.images_values?.values?.[state.current_image] ?? {}
2445
2645
  }
2446
2646
  imagesArray={state.images_values || []}
2447
2647
  setActiveImage={handleOnChangeCurrentImage}
@@ -2749,7 +2949,13 @@ const RetailerProductEditionView = ({
2749
2949
  setShowVersionSelector={setShowVersionSelector}
2750
2950
  // setSelectedImages={setSelectedImages} prop pasada asi: FullTabsMenu -> TabsMenu Pero en TabsMenu no se espera ninguna prop setSelectedImages
2751
2951
  // actions
2752
- downloadImages={handleOnDownloadImages}
2952
+ // El botón actúa sobre la bandeja visible: en activos descargaba
2953
+ // las imágenes del producto, que no es lo que el usuario pidió.
2954
+ downloadImages={
2955
+ currentTray === TRAYS.ASSETS
2956
+ ? handleOnDownloadAssets
2957
+ : handleOnDownloadImages
2958
+ }
2753
2959
  askToDeleteImages={handleOnAskToDeleteImages}
2754
2960
  showSaveButton={isAuditorAssigned() || isUserAssignedToService()}
2755
2961
  onClickSave={handleOnClickSave}
@@ -2827,7 +3033,36 @@ const RetailerProductEditionView = ({
2827
3033
  {state.active_tab === "Imágenes" &&
2828
3034
  (state.product?.images_status !== "NS" ? (
2829
3035
  <>
2830
- {!imageLayout && (
3036
+ {enableDigitalAssets && (
3037
+ <GalleryTrayTabs
3038
+ activeTray={currentTray}
3039
+ setActiveTray={setActiveTray}
3040
+ imagesCount={
3041
+ state.images_values?.values?.length ?? 0
3042
+ }
3043
+ assetsCount={digitalAssets.assets.length}
3044
+ />
3045
+ )}
3046
+ {currentTray === TRAYS.ASSETS && (
3047
+ <section
3048
+ className="container"
3049
+ style={{ position: "relative" }}
3050
+ >
3051
+ <DigitalAssetsTray
3052
+ {...digitalAssets}
3053
+ unsaved={countUnsavedAssets(state)}
3054
+ gridLayout={imageLayout}
3055
+ readOnly={
3056
+ !isAuditorAssigned() &&
3057
+ !isUserAssignedToService()
3058
+ }
3059
+ selectedAssets={state.selected_assets}
3060
+ setSelectedAssets={handleOnSetSelectedAssets}
3061
+ setCheckAll={handleOnSetAllAssetsChecked}
3062
+ />
3063
+ </section>
3064
+ )}
3065
+ {currentTray === TRAYS.IMAGES && !imageLayout && (
2831
3066
  <GalleryHeader
2832
3067
  checkAll={state.all_image_checked} // Determina si el checkbox "Seleccionar todo" está marcado
2833
3068
  setCheckAll={handleOnSetCheckAll} // Toggler de true o false para el checkbox "Seleccionar todo"
@@ -2835,74 +3070,76 @@ const RetailerProductEditionView = ({
2835
3070
  // shotThd={shotThd} // No se usa?
2836
3071
  />
2837
3072
  )}
2838
- <section
2839
- className="container"
2840
- style={{ position: "relative" }}
2841
- >
2842
- <div
2843
- {...getRootProps({
2844
- className: `dropzone ${
2845
- isDragActive ? "drag-active" : ""
2846
- }`,
2847
- })}
3073
+ {currentTray === TRAYS.IMAGES && (
3074
+ <section
3075
+ className="container"
3076
+ style={{ position: "relative" }}
2848
3077
  >
2849
- <input {...getInputProps()} />
2850
- {isDragActive && (
2851
- <div className="drag-overlay">
2852
- <p>Suelta las imágenes aquí</p>
2853
- </div>
2854
- )}
2855
- <aside>{thumbs()}</aside>
2856
- </div>
2857
- {state.images_values?.values.length === 0 && (
2858
3078
  <div
2859
- style={{
2860
- position: "absolute",
2861
- top: "50%",
2862
- left: "50%",
2863
- transform: "translate(-50%, -50%)",
2864
- textAlign: "center",
2865
- padding: "40px",
2866
- width: "80%",
2867
- maxWidth: "500px",
2868
- fontFamily: "Arial, sans-serif",
2869
- }}
3079
+ {...getRootProps({
3080
+ className: `dropzone ${
3081
+ isDragActive ? "drag-active" : ""
3082
+ }`,
3083
+ })}
2870
3084
  >
2871
- <p
3085
+ <input {...getInputProps()} />
3086
+ {isDragActive && (
3087
+ <div className="drag-overlay">
3088
+ <p>Suelta las imágenes aquí</p>
3089
+ </div>
3090
+ )}
3091
+ <aside>{thumbs()}</aside>
3092
+ </div>
3093
+ {state.images_values?.values?.length === 0 && (
3094
+ <div
2872
3095
  style={{
2873
- fontSize: "18px",
2874
- color: "#666",
2875
- marginBottom: "16px",
3096
+ position: "absolute",
3097
+ top: "50%",
3098
+ left: "50%",
3099
+ transform: "translate(-50%, -50%)",
3100
+ textAlign: "center",
3101
+ padding: "40px",
3102
+ width: "80%",
3103
+ maxWidth: "500px",
3104
+ fontFamily: "Arial, sans-serif",
2876
3105
  }}
2877
3106
  >
2878
- Este producto no tiene imágenes
2879
- </p>
2880
- {isAuditorAssigned() ||
2881
- isUserAssignedToService() ? (
2882
3107
  <p
2883
3108
  style={{
2884
- fontSize: "14px",
2885
- color: "#999",
2886
- marginBottom: "20px",
3109
+ fontSize: "18px",
3110
+ color: "#666",
3111
+ marginBottom: "16px",
2887
3112
  }}
2888
3113
  >
2889
- Arrastra las imágenes aquí o{" "}
2890
- <span
2891
- onClick={open}
3114
+ Este producto no tiene imágenes
3115
+ </p>
3116
+ {isAuditorAssigned() ||
3117
+ isUserAssignedToService() ? (
3118
+ <p
2892
3119
  style={{
2893
- color: "#007bff",
2894
- cursor: "pointer",
2895
- textDecoration: "underline",
3120
+ fontSize: "14px",
3121
+ color: "#999",
3122
+ marginBottom: "20px",
2896
3123
  }}
2897
3124
  >
2898
- haz clic para abrir el explorador de
2899
- archivos
2900
- </span>
2901
- </p>
2902
- ) : null}
2903
- </div>
2904
- )}
2905
- </section>
3125
+ Arrastra las imágenes aquí o{" "}
3126
+ <span
3127
+ onClick={open}
3128
+ style={{
3129
+ color: "#007bff",
3130
+ cursor: "pointer",
3131
+ textDecoration: "underline",
3132
+ }}
3133
+ >
3134
+ haz clic para abrir el explorador de
3135
+ archivos
3136
+ </span>
3137
+ </p>
3138
+ ) : null}
3139
+ </div>
3140
+ )}
3141
+ </section>
3142
+ )}
2906
3143
  </>
2907
3144
  ) : (
2908
3145
  <ScreenHeader