contentoh-components-library 21.6.57 → 21.6.59

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 (49) hide show
  1. package/.claude/settings.local.json +7 -0
  2. package/dist/components/pages/CreatorsProductEdition/CreatorsProductEditionView.js +90 -135
  3. package/dist/components/pages/CreatorsProductEdition/hooks/useCreatorsProductEdition.js +148 -162
  4. package/dist/components/pages/CreatorsProductEdition/mocks/api.fixtures.js +16 -0
  5. package/dist/components/pages/CreatorsProductEdition/mocks/mockApi.js +1 -0
  6. package/dist/components/pages/CreatorsProductEdition/sections/ImageGallery.js +433 -0
  7. package/dist/components/pages/CreatorsProductEdition/sections/NoticeDialog.js +155 -0
  8. package/dist/components/pages/CreatorsProductEdition/sections/RejectDialog.js +201 -0
  9. package/dist/components/pages/CreatorsProductEdition/sections/VersionDrawer.js +615 -0
  10. package/dist/components/pages/CreatorsProductEdition/stories/CreatorsProductEdition.stories.js +3 -31
  11. package/dist/components/pages/CreatorsProductEdition/stories/CreatorsProductEditionAPI.stories.js +55 -30
  12. package/dist/components/pages/CreatorsProductEdition/stories/products.js +923 -0
  13. package/dist/components/pages/CreatorsProductEdition/styles.js +27 -7
  14. package/dist/components/pages/ProviderProductEdition/ProviderProductEdition.stories.js +2 -1
  15. package/dist/components/pages/ProviderProductEdition/context/provider-product-edition.context.js +325 -10
  16. package/dist/components/pages/ProviderProductEdition/context/provider-product-edition.reducer.js +12 -2
  17. package/dist/components/pages/ProviderProductEdition/index.js +486 -231
  18. package/dist/index.js +13 -0
  19. package/package.json +1 -1
  20. package/src/components/pages/CreatorsProductEdition/CreatorsProductEditionView.jsx +777 -0
  21. package/src/components/pages/CreatorsProductEdition/constants.js +21 -0
  22. package/src/components/pages/CreatorsProductEdition/hooks/useCreatorsProductEdition.js +2238 -0
  23. package/src/components/pages/CreatorsProductEdition/index.js +19 -0
  24. package/src/components/pages/CreatorsProductEdition/mocks/api.fixtures.js +357 -0
  25. package/src/components/pages/CreatorsProductEdition/mocks/mockApi.js +144 -0
  26. package/src/components/pages/CreatorsProductEdition/sections/CommentBox.jsx +183 -0
  27. package/src/components/pages/CreatorsProductEdition/sections/ImageGallery.jsx +269 -0
  28. package/src/components/pages/CreatorsProductEdition/sections/ImagePanel.jsx +332 -0
  29. package/src/components/pages/CreatorsProductEdition/sections/ImagesSection.jsx +385 -0
  30. package/src/components/pages/CreatorsProductEdition/sections/NoticeDialog.jsx +111 -0
  31. package/src/components/pages/CreatorsProductEdition/sections/ProductHeader.jsx +305 -0
  32. package/src/components/pages/CreatorsProductEdition/sections/ProductMedia.jsx +101 -0
  33. package/src/components/pages/CreatorsProductEdition/sections/RejectDialog.jsx +125 -0
  34. package/src/components/pages/CreatorsProductEdition/sections/RetailerMenu.jsx +129 -0
  35. package/src/components/pages/CreatorsProductEdition/sections/ServiceActions.jsx +382 -0
  36. package/src/components/pages/CreatorsProductEdition/sections/StatusChip.jsx +37 -0
  37. package/src/components/pages/CreatorsProductEdition/sections/TabsBar.jsx +187 -0
  38. package/src/components/pages/CreatorsProductEdition/sections/VariantsPanel.jsx +311 -0
  39. package/src/components/pages/CreatorsProductEdition/sections/VersionDrawer.jsx +357 -0
  40. package/src/components/pages/CreatorsProductEdition/sections/tokens.js +63 -0
  41. package/src/components/pages/CreatorsProductEdition/stories/CreatorsProductEdition.stories.js +111 -0
  42. package/src/components/pages/CreatorsProductEdition/stories/CreatorsProductEditionAPI.stories.js +85 -0
  43. package/src/components/pages/CreatorsProductEdition/stories/products.js +966 -0
  44. package/src/components/pages/CreatorsProductEdition/styles.js +178 -0
  45. package/src/components/pages/ProviderProductEdition/ProviderProductEdition.stories.js +1 -0
  46. package/src/components/pages/ProviderProductEdition/context/provider-product-edition.context.jsx +201 -0
  47. package/src/components/pages/ProviderProductEdition/context/provider-product-edition.reducer.js +12 -0
  48. package/src/components/pages/ProviderProductEdition/index.js +228 -7
  49. package/src/index.js +1 -0
@@ -0,0 +1,2238 @@
1
+ /**
2
+ * Lógica de la edición de producto de Creators.
3
+ *
4
+ * COPIA LITERAL de `RetailerProductEdition/index.js` (commit ff06fba), líneas
5
+ * 122-317, 365-2182 y 2309-2320: todo lo que no es JSX. Se copió en vez de
6
+ * compartirse porque la vista actual no se puede tocar. Consecuencia: un fix
7
+ * en RetailerProductEdition hay que portarlo aquí a mano. Para ver qué cambió
8
+ * desde la copia:
9
+ *
10
+ * git diff ff06fba -- src/components/pages/RetailerProductEdition/index.js
11
+ *
12
+ * Lo único que no se copió: `thumbs` (JSX de la galería, vive en la vista) y
13
+ * el skeleton. El botón de "comentario revisado" se dejó fuera a propósito:
14
+ * en el original llama a `commentRevised()`, que no existe.
15
+ */
16
+ import { useEffect, useRef, useState } from "react";
17
+
18
+ import axios from "axios";
19
+ import { useDropzone } from "react-dropzone";
20
+ import { saveAs } from "file-saver";
21
+
22
+ import { useProviderProductEdition } from "../../RetailerProductEdition/context/provider-product-edition.context";
23
+ import { getMinorStatusFromRetailer } from "../../RetailerProductEdition/context/reducers/product";
24
+ import {
25
+ normalizeProduct,
26
+ getConceptByTab,
27
+ getAuditVersion,
28
+ getInputsData,
29
+ createMessage,
30
+ sendMessage,
31
+ translateConcept,
32
+ getStatusArrayForUser,
33
+ getConceptsByRole,
34
+ buildCollaboratorAssignations,
35
+ calculateRequiredNull,
36
+ } from "../../RetailerProductEdition/utils";
37
+ import generateThumbnail from "../../RetailerProductEdition/generateThumnail";
38
+
39
+ import {
40
+ fetchUsers,
41
+ getPercentage,
42
+ getRetailerServices,
43
+ getServicesData,
44
+ } from "../../../../global-files/data";
45
+ import { useDigitalAssets } from "../../../../global-files/digital-assets/useDigitalAssets";
46
+
47
+ import { TRAYS } from "../../../molecules/GalleryTrayTabs";
48
+ import { soloPropagables } from "../../../organisms/PropagateChangesModal/non-propagable";
49
+ import { readPayload } from "../../../organisms/LinkVariantsModal/api";
50
+
51
+ import successIcon from "../../../../assets/images/genericModal/genericModalCheck.svg";
52
+ import errorIcon from "../../../../assets/images/genericModal/errorModal.svg";
53
+ import warningIcon from "../../../../assets/images/genericModal/genericModalWarning.svg";
54
+ import attributesSent from "../../../../assets/images/modalsSVGs/attributesSent.svg";
55
+ import descriptionSent from "../../../../assets/images/modalsSVGs/descriptionSent.svg";
56
+ import imagesSent from "../../../../assets/images/modalsSVGs/uploadingImages.svg";
57
+
58
+
59
+ function obtenerDuracionVideoBase64(base64Data) {
60
+ return new Promise((resolve, reject) => {
61
+ const video = document.createElement("video");
62
+ video.src = base64Data;
63
+ video.addEventListener("loadedmetadata", () => {
64
+ resolve(video.duration);
65
+ });
66
+ video.addEventListener("error", reject);
67
+ });
68
+ }
69
+
70
+ export const useCreatorsProductEdition = ({
71
+ tabsSections,
72
+ productSelected = {},
73
+ user = {},
74
+ token,
75
+ location,
76
+ enableDigitalAssets = false,
77
+ }) => {
78
+ const {
79
+ state,
80
+ dispatch,
81
+ saveDatasheets,
82
+ saveDescriptions,
83
+ updateImages,
84
+ deleteImages,
85
+ saveAssets,
86
+ registerUploadedAssets,
87
+ handleOnDownloadImages,
88
+ createComment,
89
+ } = useProviderProductEdition();
90
+
91
+ const isRetailer = user?.is_retailer;
92
+
93
+ /*
94
+ El original usaba `useCloseModal("version-selector")`: cierra con cualquier
95
+ clic cuyo destino no esté dentro de `#version-selector`. En el diálogo de
96
+ nueva versión, el botón de «Versión personalizada» desaparece al cambiar de
97
+ paso antes de que llegue ese clic al documento, así que parecía un clic
98
+ fuera y se cerraba todo. El Drawer de `VersionDrawer` ya se cierra al tocar
99
+ fuera o con Escape.
100
+ */
101
+ const [showVersionSelector, setShowVersionSelector] = useState(false);
102
+ const [auditableVersion, setAuditableVersion] = useState(null);
103
+ const [headerTop, setHeaderTop] = useState(0);
104
+ const [shotThd, setShotThd] = useState(false);
105
+ const [observation, setObservation] = useState();
106
+ const [showRejectModal, setShowRejectModal] = useState(false);
107
+ const [isObservationVisible, setObservationVisible] = useState(false);
108
+ const [imageLayout, setImageLayout] = useState(false);
109
+ const [crossComment, setCrossComment] = useState(false);
110
+ const [statusArray, setStatusArray] = useState([]);
111
+ const [rejectAll, setRejectAll] = useState(false);
112
+
113
+ // Padre/hijo: mismo comportamiento que la ficha de la aplicación de clientes.
114
+ const [showLinkVariants, setShowLinkVariants] = useState(false);
115
+ const [family, setFamily] = useState(null);
116
+ const [showFamily, setShowFamily] = useState(false);
117
+ const [loadingVariant, setLoadingVariant] = useState(null);
118
+
119
+ /*
120
+ Agrupación de variantes. Aparte del padre/hijo a propósito: son mecanismos
121
+ independientes y un producto puede tener los dos. Padre/hijo es herencia;
122
+ esto es solo un código compartido.
123
+ */
124
+ const [variantGroup, setVariantGroup] = useState(null);
125
+ const [showGroup, setShowGroup] = useState(false);
126
+ const [showGroupModal, setShowGroupModal] = useState(false);
127
+
128
+ // Qué vínculo se está por romper, o null si no hay diálogo abierto. Guarda al
129
+ // HIJO, que es quien lleva la relación y lo que pide la acción `unlink`.
130
+ const [porDesvincular, setPorDesvincular] = useState(null);
131
+ // Por qué un salto no se pudo hacer. Vive aparte de `family.error` porque no
132
+ // es un fallo al leer la familia, sino al abrir uno de sus miembros.
133
+ const [avisoFamilia, setAvisoFamilia] = useState(null);
134
+ const [pendingPropagation, setPendingPropagation] = useState(null);
135
+ const [preparandoPropagacion, setPreparandoPropagacion] = useState(false);
136
+
137
+ const [auditServices, setAuditServices] = useState([]);
138
+ const [auditDatasheets, setAuditDatasheets] = useState([]);
139
+ const [auditDescriptions, setAuditDescriptions] = useState([]);
140
+ const [showModal, setShowModal] = useState(false);
141
+
142
+ const [compare, setCompare] = useState(false);
143
+ const { getRootProps, getInputProps, isDragActive, open } = useDropzone({
144
+ accept: "image/*, video/mp4",
145
+ noKeyboard: true,
146
+ multiple: true,
147
+ noClick: true,
148
+ onDrop: (acceptedFiles) => {
149
+ acceptedFiles.map((file) => {
150
+ const reader = new FileReader();
151
+ reader.fileName = file.name;
152
+
153
+ reader.onload = async function (e) {
154
+ const fileName = e.target.fileName;
155
+ const fileExtension = fileName.split(".").pop();
156
+ const fileDataURL = e.target.result;
157
+
158
+ const img = new Image();
159
+
160
+ if (fileExtension === "mp4") {
161
+ try {
162
+ const duracion = await obtenerDuracionVideoBase64(fileDataURL);
163
+ if (duracion <= 10 || duracion >= 600) {
164
+ dispatch({
165
+ type: "SET_MODAL",
166
+ payload: {
167
+ show: true,
168
+ title: "Hubo un error al subir el video",
169
+ message:
170
+ "Los videos deben durar entre 15 segundos y 10 minutos",
171
+ image: errorIcon,
172
+ },
173
+ });
174
+ return;
175
+ }
176
+
177
+ img.src = await generateThumbnail(fileDataURL, 2);
178
+ img.onload = function () {
179
+ const newImg = {
180
+ src: img.src,
181
+ name: fileName,
182
+ ext: fileExtension,
183
+ width: img.width,
184
+ height: img.height,
185
+ video_src: fileDataURL,
186
+ isApproved: true, // VALIDAR QUE SOLO SEA PARA RADIOSHACK
187
+ };
188
+
189
+ dispatch({
190
+ type: "ADD_IMAGE_VALUE",
191
+ payload: newImg,
192
+ });
193
+ };
194
+ } catch (error) {
195
+ console.error("Error al obtener la duración del video:", error);
196
+ }
197
+ return;
198
+ }
199
+
200
+ img.src = fileDataURL;
201
+ img.onload = function () {
202
+ const width = img.width;
203
+ const height = img.height;
204
+ const newImg = {
205
+ src: fileDataURL,
206
+ name: fileName,
207
+ ext: fileExtension,
208
+ width: width,
209
+ height: height,
210
+ video_src: "",
211
+ isApproved: true, // VALIDAR QUE SOLO SEA PARA RADIOSHACK
212
+ };
213
+
214
+ dispatch({
215
+ type: "ADD_IMAGE_VALUE",
216
+ payload: newImg,
217
+ });
218
+ };
219
+ };
220
+
221
+ reader.onerror = function (error) {
222
+ console.error("dropzoneError: ", error);
223
+ };
224
+
225
+ reader.readAsDataURL(file);
226
+ return file;
227
+ });
228
+ },
229
+ });
230
+
231
+ const [openChangeStatusModal, setOpenChangeStatusModal] = useState(false);
232
+
233
+ /*
234
+ Bandeja de activos digitales. La lista vive en el reducer de la ficha
235
+ (`state.assets_values`), igual que las imágenes, para que el guardado del
236
+ contexto pueda leerla. El hook solo aporta validación y ciclo de vida.
237
+
238
+ Sigue vacía porque todavía no hay de dónde leerla: cuando exista el endpoint
239
+ se llama a `replaceAll` desde `loadData` y el resto de la pantalla no cambia.
240
+ */
241
+ const [assetNotices, setAssetNotices] = useState([]);
242
+ const digitalAssets = useDigitalAssets({
243
+ assets: state.assets_values,
244
+ dispatch,
245
+ notices: assetNotices,
246
+ setNotices: setAssetNotices,
247
+ // Con los dos, la subida a S3 arranca sola al soltar los archivos. Sin
248
+ // ellos —en Storybook, por ejemplo— la bandeja funciona igual pero no sube.
249
+ articleId: state.product?.id_article ?? null,
250
+ token,
251
+ // Al terminar la subida el activo queda registrado solo, sin esperar al
252
+ // botón de guardar: con la barra llena, nadie supone que falta un paso.
253
+ onUploaded: (uploaded) => registerUploadedAssets(token, uploaded),
254
+ });
255
+ const [activeTray, setActiveTray] = useState(TRAYS.IMAGES);
256
+ const currentTray = enableDigitalAssets ? activeTray : TRAYS.IMAGES;
257
+
258
+ /*
259
+ De qué artículo se cargaron ya los activos.
260
+
261
+ `loadData` se vuelve a ejecutar cada vez que cambia la cadena activa, pero los
262
+ activos son generales: no dependen de la cadena ni de la versión, así que la
263
+ respuesta sería idéntica. Volver a cargarlos borraría los archivos que el
264
+ usuario acaba de soltar y todavía no guarda —sin aviso y sin forma de
265
+ recuperarlos—. Por eso se cargan una sola vez por artículo.
266
+ */
267
+ const assetsLoadedFor = useRef(null);
268
+
269
+ const handleOnSetSelectedAssets = (assets) =>
270
+ dispatch({
271
+ type: "SET_SELECTED_ASSETS",
272
+ payload: assets,
273
+ });
274
+
275
+ const handleOnSetAllAssetsChecked = (isChecked) =>
276
+ dispatch({
277
+ type: "TOGGLE_CHECK_ALL_ASSETS",
278
+ payload: isChecked,
279
+ });
280
+
281
+
282
+ const initializeProduct = async () => {
283
+ try {
284
+ const product = productSelected
285
+ ? productSelected
286
+ : JSON.parse(sessionStorage.getItem("productSelected"));
287
+
288
+ const productNormalized = normalizeProduct(product);
289
+
290
+
291
+ dispatch({
292
+ type: "SET_PRODUCT",
293
+ payload: productNormalized,
294
+ });
295
+ dispatch({
296
+ type: "SET_ACTIVE_RETAILER",
297
+ payload: productNormalized.categoryRetailerInOrder[0],
298
+ });
299
+ setShotThd(
300
+ [58, 59, 60, 61].includes(
301
+ productNormalized.categoryRetailerInOrder[0].id_retailer,
302
+ ),
303
+ );
304
+
305
+ // Los estatus evaluables salen del rol UNIDO a la asignación. Solo por
306
+ // rol, quien produce y además audita no tenía 'AC' en la lista y el
307
+ // envío se quedaba sin nada que mandar.
308
+ setStatusArray(getStatusArrayForUser(user, productNormalized));
309
+
310
+ // Cargar usuarios y asignaciones
311
+ const users = await fetchUsers(token);
312
+ if (users.length > 0) {
313
+ dispatch({
314
+ type: "SET_COLLABORATOR_ASSIGNATIONS",
315
+ payload: buildCollaboratorAssignations(productNormalized, users),
316
+ });
317
+ }
318
+
319
+ const { id_article } = productNormalized;
320
+ if (id_article) {
321
+ // Aislado: si version-manager falla (p. ej. CORS) no debe impedir
322
+ // que se cargue la observación de abajo.
323
+ try {
324
+ await getAuditVersion(id_article, setAuditableVersion, token);
325
+ } catch (auditError) {
326
+ console.warn(
327
+ "No se pudo obtener la versión auditable:",
328
+ auditError.message,
329
+ );
330
+ }
331
+ }
332
+
333
+ if (id_article && productNormalized.id_order) {
334
+ const response = await axios.get(
335
+ process.env.REACT_APP_READ_OBSERVATION,
336
+ {
337
+ params: {
338
+ articleId: id_article,
339
+ orderId: productNormalized.id_order,
340
+ },
341
+ headers: {
342
+ Authorization: sessionStorage.getItem("jwt"),
343
+ },
344
+ },
345
+ );
346
+ const parseData = JSON.parse(response.data.body).data[0];
347
+ setObservation(parseData.observations);
348
+ }
349
+ } catch (error) {
350
+ console.error("Error setting product data: ", error);
351
+ }
352
+ };
353
+
354
+ // Setter del producto y carga inicial (optimizado para evitar cascada)
355
+ useEffect(() => {
356
+ initializeProduct();
357
+ }, []);
358
+
359
+ const loadAuditableData = async () => {
360
+ const auditServices = await getRetailerServices(
361
+ state.product?.id_article,
362
+ state.product?.categoryName,
363
+ parseInt(state.product?.id_category),
364
+ auditableVersion.version,
365
+ token,
366
+ );
367
+ // getRetailerServices puede devolver undefined si falla; no seguimos.
368
+ if (!Array.isArray(auditServices)) return;
369
+ getInputsData(
370
+ auditServices,
371
+ { id: state.active_retailer.id_retailer },
372
+ setAuditDatasheets,
373
+ setAuditDescriptions,
374
+ );
375
+ setAuditServices(auditServices);
376
+ };
377
+
378
+ useEffect(() => {
379
+ if (auditableVersion) {
380
+ loadAuditableData();
381
+ }
382
+ }, [auditableVersion]);
383
+
384
+ const loadData = async (enableLoading = true, versionSelected = null) => {
385
+ try {
386
+ if (enableLoading) dispatch({ type: "SET_LOADING", payload: true });
387
+ dispatch({ type: "SET_ERRORS", payload: [] });
388
+
389
+ const { id_article, version, id_order } = state.product;
390
+ const { category, id_category, id_retailer } = state.active_retailer;
391
+
392
+ // Payloads y headers
393
+ const data = [
394
+ {
395
+ id_article,
396
+ version: versionSelected ? versionSelected : version,
397
+ relations: [
398
+ {
399
+ id_retailer,
400
+ id_category,
401
+ },
402
+ ],
403
+ },
404
+ ];
405
+
406
+ const headers = { Authorization: token };
407
+
408
+ const [services, percentagesRes, servicesDataRes, commentsResponse] =
409
+ await Promise.all([
410
+ getRetailerServices(
411
+ id_article,
412
+ category,
413
+ id_category,
414
+ versionSelected ?? version,
415
+ token,
416
+ id_retailer,
417
+ ),
418
+ getPercentage({ data, headers }),
419
+ getServicesData({
420
+ articleId: id_article,
421
+ orderId: id_order,
422
+ end: true,
423
+ token,
424
+ }),
425
+ // Obtener comentarios en paralelo
426
+ Promise.all([
427
+ axios.get(process.env.REACT_APP_COMMENTS_ENDPOINT, {
428
+ params: {
429
+ articleId: id_article,
430
+ concept: "description",
431
+ orderIdColab: id_order,
432
+ version: versionSelected ?? version,
433
+ },
434
+ }),
435
+ axios.get(process.env.REACT_APP_COMMENTS_ENDPOINT, {
436
+ params: {
437
+ articleId: id_article,
438
+ concept: "datasheet",
439
+ orderIdColab: id_order,
440
+ version: versionSelected ?? version,
441
+ },
442
+ }),
443
+ axios.get(process.env.REACT_APP_COMMENTS_ENDPOINT, {
444
+ params: {
445
+ articleId: id_article,
446
+ concept: "images",
447
+ orderIdColab: id_order,
448
+ version: versionSelected ?? version,
449
+ },
450
+ }),
451
+ ]),
452
+ ]);
453
+
454
+ // getRetailerServices se traga sus errores y devuelve undefined cuando
455
+ // alguna de sus llamadas falla. Si eso pasa, validamos aquí para lanzar
456
+ // un error controlado en vez de reventar más abajo en services[2]/services[0]
457
+ // y dejar la pantalla colgada en el skeleton para siempre.
458
+ if (!Array.isArray(services) || services.length < 3 || !services[2]) {
459
+ throw new Error(
460
+ "No se pudieron cargar los servicios del producto. Inténtalo de nuevo.",
461
+ );
462
+ }
463
+
464
+ dispatch({
465
+ type: "SET_RETAILER_STATUS",
466
+ payload: servicesDataRes.map((service) => ({
467
+ retailer_id: service?.id_retailer,
468
+ service: service?.service,
469
+ status: service?.status,
470
+ task_user_group_id: service?.task_user_group_id,
471
+ })),
472
+ });
473
+
474
+ // Extraer relaciones del response. getPercentage se traga sus errores y
475
+ // devuelve undefined (p. ej. si CORS bloquea el endpoint), así que el
476
+ // JSON.parse va protegido para no tumbar toda la carga.
477
+ let percentages = [];
478
+ try {
479
+ percentages =
480
+ JSON.parse(percentagesRes?.[0]?.body)?.[0]?.relations ?? [];
481
+ } catch (e) {
482
+ console.warn("No se pudieron parsear los porcentajes:", e.message);
483
+ }
484
+
485
+ // Procesar comentarios
486
+ let commentsMap = {};
487
+ commentsResponse.forEach((commentRes) => {
488
+ const commentData = JSON.parse(commentRes?.data?.body).data[0];
489
+ if (commentData) {
490
+ const tab = translateConcept(commentData.concept);
491
+ commentsMap[tab] = commentData;
492
+ }
493
+ });
494
+ dispatch({ type: "SET_COMMENTS", payload: commentsMap });
495
+
496
+
497
+ // Ordenamiento de imágenes
498
+ const orderMap = services[2].inputsByRetailer
499
+ .flat()
500
+ .reduce((acc, item) => {
501
+ acc[item.id_image] = item.order;
502
+ return acc;
503
+ }, {});
504
+
505
+ const orderedValues = [...services[2].values].sort((a, b) => {
506
+ const orderA = orderMap[a.image_id] ?? Number.MAX_SAFE_INTEGER;
507
+ const orderB = orderMap[b.image_id] ?? Number.MAX_SAFE_INTEGER;
508
+ return orderA - orderB;
509
+ });
510
+
511
+ // Procesamiento de inputs del retailer activo (evita cascada con state.services)
512
+ // El retailer activo puede no traer datasheets; usamos data vacía como fallback
513
+ // para no reventar en Object.values(undefined).
514
+ const retailerDatasheets = services[0]?.[id_retailer];
515
+ const datasheetsActiveRetailer = {
516
+ ...retailerDatasheets,
517
+ data: Object.values(retailerDatasheets?.data ?? {}),
518
+ };
519
+
520
+ const descriptionsActiveRetailer = services[1].filter(
521
+ (item) => item.id === id_retailer,
522
+ );
523
+
524
+ /**
525
+ * El id del retailer activo viene con dos nombres según de dónde salga:
526
+ * `categoryRetailerInOrder` lo trae como `id_retailer`, mientras que los
527
+ * retailers de `read-data-images` lo traen como `id`. Si se compara solo
528
+ * contra uno, al cambiar de cadena el otro queda `undefined`, la
529
+ * comparación es siempre falsa y `filteredValues` sale vacío — la imagen
530
+ * existe, el backend la devuelve, y aun así no se pinta ninguna.
531
+ */
532
+ const activeRetailerId = id_retailer ?? state.active_retailer?.id;
533
+
534
+ const filteredValues = services[2].values.filter((value) => {
535
+ return services[2].inputsByRetailer.some((retailerInput) =>
536
+ retailerInput.some(
537
+ (input) =>
538
+ Number(input.id_retailer) === Number(activeRetailerId) &&
539
+ Number(input.id_image) === Number(value.image_id),
540
+ ),
541
+ );
542
+ });
543
+
544
+ // Dispatch consolidado
545
+ dispatch({
546
+ type: "SET_SERVICES",
547
+ payload: {
548
+ datasheets: services[0],
549
+ descriptions: services[1],
550
+ images: {
551
+ ...services[2],
552
+ values: orderedValues,
553
+ },
554
+ },
555
+ });
556
+
557
+ dispatch({ type: "SET_PERCENTAGES", payload: percentages });
558
+ dispatch({
559
+ type: "SET_ACTIVE_PERCENTAGES",
560
+ payload: percentages.find(
561
+ ({ id_retailer: rId }) => rId === id_retailer,
562
+ ),
563
+ });
564
+ dispatch({ type: "SET_SERVICES_DATA", payload: servicesDataRes });
565
+
566
+ // Setear inputs del retailer activo directamente (evita cascada)
567
+ dispatch({
568
+ type: "SET_DATASHEETS_INPUTS",
569
+ // `services[0]?` por lo mismo que el fallback de arriba: la lectura de
570
+ // fichas puede volver sin datos para esa cadena y categoría. Arriba ya
571
+ // se contemplaba; aquí no, y como este dispatch va ANTES de
572
+ // SET_IMAGES_VALUES, reventar aquí dejaba la ficha cargando para
573
+ // siempre — con las imágenes ya traídas y sin pintar.
574
+ payload: [datasheetsActiveRetailer, services[0]?.inputs ?? {}],
575
+ });
576
+
577
+ dispatch({
578
+ type: "SET_DESCRIPTIONS_INPUTS",
579
+ payload: descriptionsActiveRetailer,
580
+ });
581
+
582
+ dispatch({
583
+ type: "SET_IMAGES_VALUES",
584
+ payload: {
585
+ ...services[2],
586
+ values: filteredValues,
587
+ },
588
+ });
589
+
590
+ /*
591
+ Activos digitales. `services[2].assets` todavía no existe en la
592
+ respuesta del backend: hasta que exista, esto no hace nada, y el día que
593
+ llegue la bandeja se llena sola sin tocar nada más.
594
+ */
595
+ if (
596
+ Array.isArray(services[2].assets) &&
597
+ assetsLoadedFor.current !== id_article
598
+ ) {
599
+ assetsLoadedFor.current = id_article;
600
+ digitalAssets.replaceAll(services[2].assets);
601
+ }
602
+
603
+ // Calcular campos requeridos sin valor
604
+ dispatch({
605
+ type: "SET_MISSING_REQUIRED_FIELDS",
606
+ payload: calculateRequiredNull(services, servicesDataRes, id_retailer),
607
+ });
608
+
609
+ // Auditable
610
+ if (auditableVersion && id_retailer) {
611
+ loadAuditableData();
612
+ }
613
+ } catch (error) {
614
+ console.error("Error loading data: ", error);
615
+ dispatch({ type: "SET_ERRORS", payload: [error.message] });
616
+ } finally {
617
+ dispatch({ type: "SET_LOADING", payload: false });
618
+ }
619
+ };
620
+
621
+ useEffect(() => {
622
+ if (state.active_retailer) {
623
+ loadData();
624
+ }
625
+ }, [state.active_retailer]);
626
+
627
+ const handleOnChangeCurrentImage = (index) => {
628
+ if (typeof index === "number") {
629
+ dispatch({ type: "SET_CURRENT_IMAGE", payload: index });
630
+ } else if (typeof index === "object") {
631
+ dispatch({ type: "CHANGE_ATTR_VALUE", payload: index });
632
+ }
633
+ };
634
+
635
+ const handleOnShowModalGallery = () => {
636
+ dispatch({
637
+ type: "SET_ACTIVE_TAB",
638
+ payload: "Imágenes",
639
+ });
640
+ setShowModal(true);
641
+ };
642
+
643
+ const handleOnChangeActiveRetailer = (retailer) => {
644
+ dispatch({
645
+ type: "SET_ACTIVE_RETAILER",
646
+ payload: retailer,
647
+ });
648
+ };
649
+
650
+ const handleOnChangeActiveTab = (tab) => {
651
+ dispatch({
652
+ type: "SET_ACTIVE_TAB",
653
+ payload: tab,
654
+ });
655
+ };
656
+
657
+ const handleOnToggleImageLayout = () => {
658
+ setImageLayout(!imageLayout);
659
+ };
660
+
661
+ const handleOnToggleObservation = () => {
662
+ setObservationVisible(!isObservationVisible);
663
+ };
664
+
665
+ const handleOnHideObservation = () => {
666
+ setObservationVisible(false);
667
+ };
668
+
669
+ const isAuditorAssigned = () => {
670
+ return state.product?.id_auditor === user?.id_user;
671
+ };
672
+
673
+ /**
674
+ * ¿Ocupo este slot EN ESTE producto?
675
+ *
676
+ * La calidad en la que actúas sale de la ASIGNACIÓN, no del rol. Con la
677
+ * estructura nueva de Creators, quien produce un servicio también lo valida:
678
+ * un especialista de textos puede ser el auditor del mismo producto y tiene
679
+ * que recorrer las dos fases, AS -> AC como especialista y AC -> AA como QA.
680
+ */
681
+ const isAssignedAs = (rol, concept) => {
682
+ const assigned = state.product?.[`id_${concept}_${rol}`];
683
+ return !!assigned && assigned === user?.id_user;
684
+ };
685
+
686
+ const isUserAssignedToService = (tab, rol) => {
687
+ const concept = getConceptByTab(state.active_tab);
688
+
689
+ const allowedRoles = [1, 4, 5, 6, 7, 8];
690
+ const validUser = allowedRoles.includes(user?.id_role);
691
+
692
+ const isAdmin = user.id_role === 1;
693
+ if (isAdmin) return true;
694
+
695
+ // Sin `rol` explícito se prueba por asignación, no por rol. Antes se
696
+ // infería del `id_role`: un rol 6 caía en `id_<concept>_undefined` —columna
697
+ // inexistente, siempre falso— y un rol 7 solo podía ser especialista, nunca
698
+ // auditor. Es el mismo defecto que se corrigió en el backend.
699
+ const isAssigned = rol
700
+ ? isAssignedAs(rol, concept)
701
+ : isAssignedAs("especialist", concept) ||
702
+ isAssignedAs("facilitator", concept);
703
+
704
+ return isAssigned && validUser;
705
+ };
706
+
707
+ const canShowSingleEvaluationButtons = (action) => {
708
+ let concept = getConceptByTab(action || state.active_tab);
709
+
710
+ const retailerStatus = state.services_data
711
+ ?.find(
712
+ (srv) =>
713
+ srv.id_retailer === state.active_retailer?.id_retailer &&
714
+ srv.service === concept,
715
+ )
716
+ ?.status?.replace(/.*\//, "");
717
+
718
+ // Se SUMA la vía por asignación a la que ya había por rol; no la
719
+ // reemplaza. Así los coordinadores y el administrador conservan su acceso
720
+ // tal cual, y el especialista que además es auditor alcanza la segunda fase.
721
+ const canEvaluateAsFacilitator =
722
+ retailerStatus === "IE" &&
723
+ ([1, 4, 5].includes(user.id_role) ||
724
+ isAssignedAs("facilitator", concept));
725
+
726
+ const canEvaluateAsAuditor =
727
+ ["AC", "RP", "RCA", "SAC"].includes(retailerStatus) &&
728
+ ([1, 6].includes(user.id_role) || isAuditorAssigned());
729
+
730
+ // Los roles 7 y 8 también pueden validar/rechazar lo que está en AC.
731
+ const canEvaluateAC =
732
+ retailerStatus === "AC" && [7, 8].includes(user.id_role);
733
+
734
+ return canEvaluateAsFacilitator || canEvaluateAsAuditor || canEvaluateAC;
735
+ };
736
+
737
+ const canShowBulkEvaluationButtons = () => {
738
+ const concepts = getConceptsByRole(user.id_role);
739
+
740
+ const services = state.services_data.filter(({ service }) =>
741
+ concepts.includes(service),
742
+ );
743
+
744
+ const canEvaluateAsFacilitator =
745
+ services.every((srv) => srv?.status?.replace(/.*\//, "") === "IE") &&
746
+ ([1, 4, 5].includes(user.id_role) ||
747
+ services.some(({ service }) => isAssignedAs("facilitator", service)));
748
+
749
+ const canEvaluateAsAuditor =
750
+ state.services_data.every((srv) =>
751
+ ["AC", "RP", "RCA"].includes(srv?.status?.replace(/.*\//, "")),
752
+ ) &&
753
+ ([1, 6].includes(user.id_role) || isAuditorAssigned());
754
+
755
+ const canEvaluateAC =
756
+ services.length > 0 &&
757
+ services.every((srv) => srv?.status?.replace(/.*\//, "") === "AC") &&
758
+ [7, 8].includes(user.id_role);
759
+
760
+ return canEvaluateAsFacilitator || canEvaluateAsAuditor || canEvaluateAC;
761
+ };
762
+
763
+ const getStatusByCurrentServiceAndRetailer = () => {
764
+ const { statusByRetailer } = state.product;
765
+ if (!statusByRetailer) return "-";
766
+
767
+ const currentService = getConceptByTab(state.active_tab);
768
+ const currentRetailer = state.active_retailer.id_retailer;
769
+ const currentStatus =
770
+ statusByRetailer.filter(
771
+ (item) =>
772
+ item.retailer_id === currentRetailer &&
773
+ item.service === currentService,
774
+ )[0]?.status || "-";
775
+
776
+ return currentStatus;
777
+ };
778
+
779
+ /*
780
+ Guarda los activos y recarga si algo se escribió.
781
+
782
+ La recarga no es opcional: un activo recién creado sigue en memoria con su
783
+ id temporal y sin el id de la base. Sin volver a leer, el siguiente guardado
784
+ lo mandaría otra vez como nuevo y se duplicaría. Poner el ref en `null` es
785
+ lo que permite que `loadData` lo vuelva a traer —normalmente los activos se
786
+ cargan una sola vez por artículo—.
787
+ */
788
+ const handleOnSaveAssets = async () => {
789
+ const guardado = await saveAssets(token);
790
+ if (!guardado) return;
791
+
792
+ assetsLoadedFor.current = null;
793
+ await loadData(false);
794
+ };
795
+
796
+ const handleOnClickSaveImages = async () => {
797
+ if (currentTray === TRAYS.ASSETS) {
798
+ await handleOnSaveAssets();
799
+ return;
800
+ }
801
+
802
+ if (state.product?.services?.images === 1) {
803
+ /**
804
+ * `updateImages` y NO `saveImageAttrs`.
805
+ *
806
+ * `saveImageAttrs` manda `articleData: []` fijo, así que las imágenes
807
+ * nuevas nunca llegaban al backend: el archivo se subía a S3 —eso lo hace
808
+ * el front por su cuenta— pero `saveImages` no se ejecutaba y la fila de
809
+ * `rel_article_image` nunca se creaba. Como la pantalla lee de esa tabla,
810
+ * la imagen no aparecía después de guardar.
811
+ *
812
+ * `updateImages` cubre las dos cosas: manda `attrReqImgs` con los
813
+ * atributos requeridos y además `articleData` con las imágenes nuevas y
814
+ * `updateImages` con las existentes. Es la misma función que ya usaba el
815
+ * botón de guardar del menú de pestañas (`handleOnClickSave`), así que
816
+ * ambos caminos quedan consistentes.
817
+ */
818
+ await updateImages(token);
819
+ // Recargar para que la pantalla refleje lo que quedó guardado, igual que
820
+ // `handleOnClickSave`. Sin esto el estado local se queda con la imagen sin
821
+ // su `id` de base de datos: la vista no muestra lo persistido y, al volver
822
+ // a guardar, el front la manda otra vez como nueva y el backend la omite
823
+ // por duplicada — así que el `src` nunca se actualizaba.
824
+ await loadData(false);
825
+ }
826
+ };
827
+
828
+ /*
829
+ Trae la familia: los hijos del producto, o sus hermanos si el abierto es
830
+ una de ellas. Se pide al abrir la sección y no al montar la pantalla: la
831
+ mayoría de los productos no son familia.
832
+ */
833
+ const loadFamily = async (forzar = false) => {
834
+ if (family && !forzar) return family;
835
+ try {
836
+ const res = await axios.post(
837
+ `${process.env.REACT_APP_VARIANTS_ENDPOINT}/family`,
838
+ { articleId: state.product?.id_article },
839
+ { headers: { Authorization: token } },
840
+ );
841
+ const data = readPayload(res);
842
+ setFamily(data);
843
+ return data;
844
+ } catch (error) {
845
+ console.error("loadFamily: ", error);
846
+ setFamily({ variants: [], parent: null, error: true });
847
+ return null;
848
+ }
849
+ };
850
+
851
+ /*
852
+ En Creators el parentesco se pregunta, no viene con el producto.
853
+
854
+ Su listado sale del endpoint de reportes, que no trae `variantsCount` ni
855
+ `parentArticleId`: esas columnas se agregaron a las vistas pero DynamoDB no
856
+ las tiene hasta que se corra el recorrido de datos. Y aunque las tuviera,
857
+ dependería de lo actualizado que esté ese recorrido.
858
+
859
+ Preguntar a `family` cuesta una llamada por ficha y da el dato de la base, no
860
+ de una copia. Además aplica el alcance del perfil (§5.1): al equipo interno le
861
+ devuelve los productos solicitados a ContentOh, que es justo lo que puede
862
+ tocar.
863
+ */
864
+ useEffect(() => {
865
+ if (!state.product?.id_article) return;
866
+ loadFamily(true);
867
+ // eslint-disable-next-line react-hooks/exhaustive-deps
868
+ }, [state.product?.id_article]);
869
+
870
+ // El conteo sale de la familia recién consultada, no del producto.
871
+ const hijosDelProducto = family?.error
872
+ ? 0
873
+ : (family?.variants ?? []).filter((v) => !v.isCurrent).length;
874
+
875
+ const esHijoDeOtro = family?.error
876
+ ? !!state.product?.parentArticleId
877
+ : !!family?.parent;
878
+
879
+ /*
880
+ Un hijo no se agrupa como variante (decisión de producto, 2026-09-25).
881
+
882
+ Agrupar declara «estos son el mismo producto en otra presentación», y un
883
+ hijo ya declaró de dónde deriva. El servidor lo rechaza con
884
+ VARIANT_GROUP_NOT_FOR_CHILD, así que ofrecer el botón sería ofrecer algo
885
+ que siempre falla.
886
+
887
+ Se apoya en `esHijoDeOtro` y no en `state.product` porque en esta pantalla
888
+ el producto sale de sessionStorage: la señal buena es la familia recién
889
+ consultada, con el producto solo como respaldo si esa consulta falló.
890
+ */
891
+ const puedeAgruparse = !esHijoDeOtro;
892
+
893
+ /*
894
+ Abre otro producto de la familia.
895
+
896
+ Cambiar la URL no basta: esta pantalla no lee el producto de la ruta sino de
897
+ sessionStorage —el router hace JSON.parse(sessionStorage.productSelected) y
898
+ se lo pasa como prop—, así que navegar sin precargar dejaría la URL nueva
899
+ mostrando el producto anterior. Por eso primero se trae el destino y se
900
+ escriben las tres llaves que el listado escribe al hacer clic en una fila
901
+ (productSelected, version, productEdit), y solo entonces se navega.
902
+
903
+ Se pide a /ordered-services y no a read-articles-v2, que es lo que usa la
904
+ aplicación de clientes: aquel filtra por la compañía del usuario y el equipo
905
+ interno es de ContentOh trabajando productos de sus clientes, así que ahí no
906
+ devolvería nada. /ordered-services es el mismo endpoint del listado de esta
907
+ aplicación, así que el objeto llega con la forma exacta que normalizeProduct
908
+ espera —con `article` anidado, `statusByRetailer` y `categoryRetailer`—.
909
+
910
+ Un producto de la familia puede no estar en ninguna orden de trabajo: la
911
+ relación padre/hijo vive en `article` y no exige haber pasado por ContentOh.
912
+ En ese caso no hay nada que abrir aquí, y se dice en vez de navegar a una
913
+ pantalla que mostraría el producto equivocado.
914
+ */
915
+ /*
916
+ Después de desvincular, esta ficha cambia de forma: si se soltó de su padre
917
+ vuelve a poder ligar, y si soltó a un hijo pierde uno de la cuenta. En esta
918
+ pantalla el bloque de parentesco se decide con `family`, así que hay que
919
+ repreguntarla —vaciarla lo haría desaparecer, que es el error que ya se
920
+ cometió una vez aquí—.
921
+ */
922
+ const handleOnUnlinked = (result) => {
923
+ if (!result) return;
924
+
925
+ const updatedProduct =
926
+ result.mode === "self"
927
+ ? { ...state.product, parentArticleId: null, variantsCount: 0 }
928
+ : {
929
+ ...state.product,
930
+ variantsCount: Math.max((state.product?.variantsCount ?? 1) - 1, 0),
931
+ };
932
+
933
+ dispatch({ type: "SET_PRODUCT", payload: updatedProduct });
934
+
935
+ // Se PARCHEA lo guardado: ahí vive el producto con la forma del listado
936
+ // —`article` anidado, servicios, cadenas— y escribir encima la versión
937
+ // aplanada de esta pantalla lo dejaría mutilado en el siguiente F5.
938
+ try {
939
+ const guardado = JSON.parse(
940
+ sessionStorage.getItem("productSelected") ?? "null",
941
+ );
942
+
943
+ if (guardado) {
944
+ sessionStorage.setItem(
945
+ "productSelected",
946
+ JSON.stringify({
947
+ ...guardado,
948
+ variantsCount: updatedProduct.variantsCount,
949
+ parentArticleId: updatedProduct.parentArticleId ?? null,
950
+ }),
951
+ );
952
+ }
953
+ } catch (error) {
954
+ console.error("unlinkVariant/sessionStorage: ", error);
955
+ }
956
+
957
+ /*
958
+ Si ya no queda familia, se cierra el desplegable: su botón desaparece con
959
+ el vínculo, y dejarlo abierto pinta una caja que dice «no tiene hijos» y
960
+ que nadie puede cerrar.
961
+ */
962
+ if (
963
+ !updatedProduct.parentArticleId &&
964
+ (updatedProduct.variantsCount ?? 0) === 0
965
+ ) {
966
+ setShowFamily(false);
967
+ }
968
+
969
+ loadFamily(true);
970
+
971
+ // Al desvincular el servidor pone `is_overridden = 0` en todas las
972
+ // versiones, así que los chips de herencia tienen que dejar de dibujarse.
973
+ // Sin `enableLoading` para no parpadear el esqueleto encima de una pantalla
974
+ // que ya está pintada.
975
+ loadData(false);
976
+ };
977
+
978
+ /*
979
+ Trae el grupo de variantes y sus miembros.
980
+
981
+ Al montar, no al abrir el desplegable: `variant_group` no lo devuelve el
982
+ endpoint del listado, así que sin esta llamada la ficha no sabe si el
983
+ producto está agrupado.
984
+ */
985
+ const loadVariantGroup = async (forzar = false) => {
986
+ if (variantGroup && !forzar) return variantGroup;
987
+ try {
988
+ const res = await axios.post(
989
+ `${process.env.REACT_APP_VARIANTS_ENDPOINT}/group`,
990
+ { articleId: state.product?.id_article },
991
+ { headers: { Authorization: token } },
992
+ );
993
+ const data = readPayload(res);
994
+ setVariantGroup(data);
995
+ return data;
996
+ } catch (error) {
997
+ console.error("loadVariantGroup: ", error);
998
+ setVariantGroup({ group: null, members: [], error: true });
999
+ return null;
1000
+ }
1001
+ };
1002
+
1003
+ useEffect(() => {
1004
+ if (!state.product?.id_article) return;
1005
+ loadVariantGroup(true);
1006
+ // eslint-disable-next-line react-hooks/exhaustive-deps
1007
+ }, [state.product?.id_article]);
1008
+
1009
+ const handleOnLeaveGroup = async () => {
1010
+ try {
1011
+ const res = await axios.post(
1012
+ `${process.env.REACT_APP_VARIANTS_ENDPOINT}/leave`,
1013
+ { articleId: state.product?.id_article },
1014
+ { headers: { Authorization: token } },
1015
+ );
1016
+ setVariantGroup(readPayload(res));
1017
+ setShowGroup(false);
1018
+ } catch (error) {
1019
+ console.error("leaveVariantGroup: ", error);
1020
+ dispatch({
1021
+ type: "SET_MODAL",
1022
+ payload: {
1023
+ show: true,
1024
+ title: "Variantes",
1025
+ message: "No se pudo quitar el producto del grupo. Intenta de nuevo.",
1026
+ image: warningIcon,
1027
+ buttons: [
1028
+ {
1029
+ text: "Entendido",
1030
+ buttonType: "general-white-button",
1031
+ action: () =>
1032
+ dispatch({ type: "SET_MODAL", payload: { show: false } }),
1033
+ },
1034
+ ],
1035
+ },
1036
+ });
1037
+ }
1038
+ };
1039
+
1040
+ const openFamilyMember = async (articleId) => {
1041
+ setLoadingVariant(articleId);
1042
+ setAvisoFamilia(null);
1043
+ try {
1044
+ const res = await axios.get(
1045
+ `${process.env.REACT_APP_ORDERED_SERVICES_ENDPOINT}`,
1046
+ {
1047
+ params: { filterByIdArticle: articleId, includeFilters: false },
1048
+ headers: { Authorization: token },
1049
+ },
1050
+ );
1051
+
1052
+ // read-order-services responde { total, data: [...] }. La vista trae una
1053
+ // fila por orden, así que puede haber varias del mismo artículo; llegan
1054
+ // ordenadas por order_id DESC y la primera es la orden más reciente.
1055
+ const { data } = readPayload(res) ?? {};
1056
+ const encontrado = (data ?? []).find(
1057
+ (item) => Number(item?.article?.id_article) === Number(articleId),
1058
+ );
1059
+
1060
+ if (!encontrado) {
1061
+ setAvisoFamilia(
1062
+ "Ese producto no está en ninguna orden de trabajo, así que no se puede abrir desde aquí.",
1063
+ );
1064
+ return;
1065
+ }
1066
+
1067
+ sessionStorage.setItem("productSelected", JSON.stringify(encontrado));
1068
+ sessionStorage.setItem("version", JSON.stringify(encontrado.version));
1069
+ sessionStorage.setItem(
1070
+ "productEdit",
1071
+ JSON.stringify({
1072
+ ArticleId: encontrado.article.id_article,
1073
+ categoryId: encontrado.article.id_article,
1074
+ product: [encontrado],
1075
+ }),
1076
+ );
1077
+
1078
+ window.location.assign("/EditProducts");
1079
+ } catch (error) {
1080
+ console.error("openFamilyMember: ", error);
1081
+ setAvisoFamilia("No se pudo abrir ese producto. Intenta de nuevo.");
1082
+ } finally {
1083
+ setLoadingVariant(null);
1084
+ }
1085
+ };
1086
+
1087
+ /*
1088
+ Refresca tras ligar, sin recargar la página: el producto sale de
1089
+ sessionStorage, así que un F5 devolvería el mismo objeto y el contador
1090
+ seguiría igual.
1091
+ */
1092
+ const handleOnLinkedVariants = (result) => {
1093
+ if (!result) return;
1094
+
1095
+ const updatedProduct =
1096
+ result.mode === "children"
1097
+ ? {
1098
+ ...state.product,
1099
+ variantsCount:
1100
+ (state.product?.variantsCount ?? 0) +
1101
+ (result.linked ?? []).length,
1102
+ }
1103
+ : {
1104
+ ...state.product,
1105
+ parentArticleId: result.parentArticleId ?? null,
1106
+ variantsCount: 0,
1107
+ };
1108
+
1109
+ dispatch({ type: "SET_PRODUCT", payload: updatedProduct });
1110
+
1111
+ /*
1112
+ En sessionStorage se PARCHEA lo guardado; no se reemplaza.
1113
+
1114
+ Lo que hay ahí es el producto tal como lo devuelve el listado de esta
1115
+ aplicación: con `article` anidado, `statusByRetailer`, `categoryRetailer`
1116
+ y los servicios. `updatedProduct` es la versión aplanada que usa esta
1117
+ pantalla, y escribirla encima tiraba todo eso: al siguiente F5 la ficha se
1118
+ reconstruía desde un objeto mutilado —sin relaciones de cadena, sin
1119
+ estatus por cadena— aunque en pantalla se viera bien hasta entonces.
1120
+
1121
+ De ligar solo cambian dos cosas, y son las dos que se copian aquí.
1122
+ */
1123
+ try {
1124
+ const guardado = JSON.parse(
1125
+ sessionStorage.getItem("productSelected") ?? "null",
1126
+ );
1127
+
1128
+ if (guardado) {
1129
+ sessionStorage.setItem(
1130
+ "productSelected",
1131
+ JSON.stringify({
1132
+ ...guardado,
1133
+ variantsCount: updatedProduct.variantsCount,
1134
+ parentArticleId: updatedProduct.parentArticleId ?? null,
1135
+ }),
1136
+ );
1137
+ }
1138
+ } catch (error) {
1139
+ // Un sessionStorage inaccesible no debe tumbar la pantalla: el estado en
1140
+ // memoria ya quedó bien y es lo que el usuario está viendo.
1141
+ console.error("linkVariants/sessionStorage: ", error);
1142
+ }
1143
+
1144
+ /*
1145
+ Volver a preguntar, no vaciar.
1146
+
1147
+ Antes esto hacía `setFamily(null)` para invalidar la caché, y en esta
1148
+ pantalla eso tenía el efecto contrario al buscado: el bloque de parentesco
1149
+ se decide con `esHijoDeOtro`, que sale de `family`, no del producto. Con
1150
+ `family` en null el bloque desaparecía, así que después de ligar la
1151
+ pantalla se quedaba SIN ninguna señal de que el producto ya tenía padre
1152
+ —justo lo que el usuario acababa de hacer—.
1153
+ */
1154
+ loadFamily(true);
1155
+
1156
+ /*
1157
+ Y se recargan los servicios, porque ligar cambia la herencia de cada campo
1158
+ en el servidor: al planchar, lo que era propio pasa a heredado y los chips
1159
+ tienen que decirlo. Sin esto la ficha sigue mostrando el estado anterior
1160
+ hasta que alguien cierra y vuelve a abrir el producto.
1161
+
1162
+ Sin `enableLoading` para no parpadear el esqueleto encima de una pantalla
1163
+ que ya está pintada.
1164
+ */
1165
+ loadData(false);
1166
+ };
1167
+
1168
+ /*
1169
+ Acción "Volver a heredar" de los chips. Repinta en sitio con lo que devuelve
1170
+ el endpoint: recargar la ficha se llevaría lo que haya sin guardar.
1171
+ */
1172
+ /*
1173
+ `kind` distingue de qué pestaña salió el chip. Las descripciones viven en
1174
+ `rel_article_description` y la ficha técnica en `article_data`: el endpoint
1175
+ recibe una lista o la otra.
1176
+ */
1177
+ const handleOnRestoreInheritance = async (
1178
+ attributeId,
1179
+ kind = "attribute",
1180
+ ) => {
1181
+ if (!attributeId || !state.product?.id_article) return;
1182
+
1183
+ const esDescripcion = kind === "description";
1184
+
1185
+ try {
1186
+ const res = await axios.post(
1187
+ `${process.env.REACT_APP_VARIANTS_ENDPOINT}/restore`,
1188
+ {
1189
+ articleId: state.product.id_article,
1190
+ ...(esDescripcion
1191
+ ? { descriptions: [Number(attributeId)] }
1192
+ : { attributes: [Number(attributeId)] }),
1193
+ },
1194
+ { headers: { Authorization: token } },
1195
+ );
1196
+
1197
+ const data = readPayload(res);
1198
+
1199
+ if (esDescripcion) {
1200
+ dispatch({
1201
+ type: "UPDATE_DESCRIPTIONS_INPUTS",
1202
+ payload: (data.descriptionValues || []).map((item) => ({
1203
+ attributeId: item.id_description,
1204
+ value: item.value,
1205
+ isOverridden: 0,
1206
+ })),
1207
+ });
1208
+ return;
1209
+ }
1210
+
1211
+ dispatch({
1212
+ type: "UPDATE_DATASHEETS_INPUTS",
1213
+ payload: (data.values || []).map((item) => ({
1214
+ attributeId: item.id,
1215
+ value: item.value,
1216
+ isOverridden: 0,
1217
+ })),
1218
+ });
1219
+ } catch (error) {
1220
+ console.error("restoreInheritance: ", error);
1221
+ }
1222
+ };
1223
+
1224
+ /*
1225
+ Los atributos recién tocados, con el nombre que ve el usuario:
1226
+ `updated_datasheets_inputs` solo trae { attributeId, value }.
1227
+ */
1228
+ const cambiosDeFichaTecnica = () => {
1229
+ const retailerId = state.active_retailer?.id_retailer;
1230
+ const inputs = state.services?.datasheets?.inputs ?? {};
1231
+
1232
+ const cambios = (state.updated_datasheets_inputs ?? []).map((item) => {
1233
+ const input = inputs[`${item.attributeId}_${retailerId}`];
1234
+ return {
1235
+ id: Number(item.attributeId),
1236
+ name: input?.name ?? `Atributo ${item.attributeId}`,
1237
+ value: item.value,
1238
+ };
1239
+ });
1240
+
1241
+ /*
1242
+ UPC y SKU se quitan aquí y no dentro del diálogo porque esta lista también
1243
+ decide SI el diálogo se abre. Editar solo el UPC no tiene nada que
1244
+ propagar, así que preguntarlo sería pedirle al usuario una decisión sobre
1245
+ un conjunto vacío.
1246
+ */
1247
+ return soloPropagables(cambios);
1248
+ };
1249
+
1250
+ /*
1251
+ Lo que se acaba de editar en la pestaña de descripciones.
1252
+
1253
+ Va aparte de `cambiosDeFichaTecnica` porque son tablas distintas y el
1254
+ endpoint recibe una lista o la otra. Aquí NO se filtran UPC ni SKU: esos son
1255
+ atributos de ficha técnica y no aparecen en esta pestaña.
1256
+ */
1257
+ const cambiosDeDescripciones = () => {
1258
+ const retailerId = state.active_retailer?.id_retailer;
1259
+ const inputs = state.services?.descriptions?.inputs ?? {};
1260
+
1261
+ return (state.updated_descriptions_inputs ?? []).map((item) => {
1262
+ const input = inputs[`${item.attributeId}_${retailerId}`];
1263
+ return {
1264
+ id: Number(item.attributeId),
1265
+ name: input?.name ?? `Descripción ${item.attributeId}`,
1266
+ value: item.value,
1267
+ };
1268
+ });
1269
+ };
1270
+
1271
+ const handleOnClickSave = async () => {
1272
+ if (state.saving || preparandoPropagacion) return;
1273
+
1274
+ switch (state.active_tab) {
1275
+ case "Descripción": {
1276
+ /*
1277
+ Guardar una descripción en un padre también abre la decisión de
1278
+ propagar. Sin esto, el hijo heredaba la descripción al ligarse y se
1279
+ quedaba con la versión vieja en cuanto el padre la corregía, sin que
1280
+ nadie lo notara.
1281
+ */
1282
+ const cambiosDesc = cambiosDeDescripciones();
1283
+ const companerasDesc = (variantGroup?.members ?? []).filter(
1284
+ (m) => !m.isCurrent
1285
+ );
1286
+
1287
+ if (esHijoDeOtro || cambiosDesc.length === 0) {
1288
+ await saveDescriptions(token);
1289
+ break;
1290
+ }
1291
+
1292
+ setPreparandoPropagacion(true);
1293
+ let familiaDesc = null;
1294
+ try {
1295
+ familiaDesc = await loadFamily(true);
1296
+ } finally {
1297
+ setPreparandoPropagacion(false);
1298
+ }
1299
+
1300
+ const hijosDesc = (familiaDesc?.variants ?? []).filter(
1301
+ (v) => !v.isCurrent
1302
+ );
1303
+
1304
+ if (hijosDesc.length > 0 || companerasDesc.length > 0) {
1305
+ setPendingPropagation({ cambios: cambiosDesc, kind: "description" });
1306
+ return;
1307
+ }
1308
+
1309
+ await saveDescriptions(token);
1310
+ break;
1311
+ }
1312
+ case "Ficha técnica": {
1313
+ const cambios = cambiosDeFichaTecnica();
1314
+
1315
+ if (cambios.length === 0) {
1316
+ await saveDatasheets(token);
1317
+ break;
1318
+ }
1319
+
1320
+ /*
1321
+ Guardar un padre con hijos abre la decisión de propagar. Sin este paso
1322
+ el cambio se queda solo en el padre y los hijos se van separando de él
1323
+ sin que nadie lo note.
1324
+
1325
+ Si tiene hijos se resuelve AQUÍ, esperando la respuesta, en vez de
1326
+ mirar la copia en memoria. `family` es una consulta asíncrona: si
1327
+ todavía no llegó, o si falló, `hijosDelProducto` vale 0 — o sea que
1328
+ «todavía no sé» se leía igual que «no tiene hijos», y el padre se
1329
+ guardaba sin ofrecer la propagación. En silencio, que es lo peor: el
1330
+ usuario creía haber bajado el cambio a los hijos.
1331
+
1332
+ La ficha de la aplicación de clientes no tiene el problema porque ahí
1333
+ `state.product.variantsCount` viene del listado; aquí el producto sale
1334
+ del endpoint de reportes, que no trae el parentesco.
1335
+ */
1336
+ setPreparandoPropagacion(true);
1337
+ let familia = null;
1338
+ try {
1339
+ familia = await loadFamily(true);
1340
+ } finally {
1341
+ setPreparandoPropagacion(false);
1342
+ }
1343
+
1344
+ // No se pudo saber si tiene hijos. Guardar de todas formas repetiría el
1345
+ // defecto que se está corrigiendo, así que se dice y se deja la decisión.
1346
+ if (!familia || familia.error) {
1347
+ dispatch({
1348
+ type: "SET_MODAL",
1349
+ payload: {
1350
+ show: true,
1351
+ title: "No se pudo consultar la familia",
1352
+ message:
1353
+ "No pudimos saber si este producto tiene hijos, así que no sabemos si hay que bajarles el cambio. Vuelve a intentar el guardado; si sigue fallando, guarda y revisa los hijos a mano.",
1354
+ image: warningIcon,
1355
+ buttons: [
1356
+ {
1357
+ text: "Entendido",
1358
+ buttonType: "general-white-button",
1359
+ action: () =>
1360
+ dispatch({ type: "SET_MODAL", payload: { show: false } }),
1361
+ },
1362
+ {
1363
+ text: "Guardar solo este producto",
1364
+ buttonType: "general-button-default",
1365
+ action: () => {
1366
+ dispatch({ type: "SET_MODAL", payload: { show: false } });
1367
+ saveDatasheets(token);
1368
+ },
1369
+ },
1370
+ ],
1371
+ },
1372
+ });
1373
+ return;
1374
+ }
1375
+
1376
+ /*
1377
+ Un HIJO no propaga a nadie, y hay que decirlo explícitamente.
1378
+
1379
+ `familia.variants` mira la familia desde el PADRE —eso es lo correcto
1380
+ para navegar entre hermanos— así que desde la ficha de un hijo esa
1381
+ lista son sus HERMANOS. Leerla como «los destinos de este producto»
1382
+ hacía que el diálogo le ofreciera heredar a sus hermanos. El servidor
1383
+ resolvía los destinos por su cuenta y no escribía nada, de modo que el
1384
+ usuario elegía, guardaba, y no pasaba nada: un no-operativo silencioso.
1385
+ */
1386
+ const esHijo = !!familia?.parent;
1387
+ const hijos = esHijo
1388
+ ? []
1389
+ : (familia.variants ?? []).filter((v) => !v.isCurrent);
1390
+
1391
+ /*
1392
+ También cuentan las compañeras de grupo: un producto puede no tener
1393
+ hijos y aun así pertenecer a un grupo de variantes, y el cambio se les
1394
+ puede copiar igual. Si no se miraran, guardar no ofrecería nada.
1395
+ */
1396
+ const companeras = (variantGroup?.members ?? []).filter(
1397
+ (m) => !m.isCurrent,
1398
+ );
1399
+
1400
+ // Un hijo tampoco puede estar agrupado desde 2026-09-25, así que para
1401
+ // él no queda ningún destino posible.
1402
+ if (!esHijo && (hijos.length > 0 || companeras.length > 0)) {
1403
+ setPendingPropagation({ cambios, kind: "datasheet" });
1404
+ return;
1405
+ }
1406
+
1407
+ await saveDatasheets(token);
1408
+ break;
1409
+ }
1410
+ case "Imágenes":
1411
+ /*
1412
+ Se guarda la bandeja que el usuario tiene a la vista. Guardar las dos
1413
+ de golpe haría que, estando en activos, `updateImages` enviara también
1414
+ los cambios de imágenes que el usuario quizá no dio por terminados —y
1415
+ al revés—. El botón guarda lo que se está viendo.
1416
+ */
1417
+ if (currentTray === TRAYS.ASSETS) {
1418
+ await handleOnSaveAssets();
1419
+ return;
1420
+ }
1421
+ await updateImages(token);
1422
+ break;
1423
+ default:
1424
+ break;
1425
+ }
1426
+
1427
+ await loadData(false);
1428
+ };
1429
+
1430
+ const handleOnSetUpdatedDescriptions = (items) => {
1431
+ dispatch({
1432
+ type: "SET_UPDATED_DESCRIPTIONS_INPUTS",
1433
+ payload: items,
1434
+ });
1435
+ };
1436
+
1437
+ const handleOnSetUpdatedDatasheets = (items) => {
1438
+ dispatch({
1439
+ type: "SET_UPDATED_DATASHEETS_INPUTS",
1440
+ payload: items,
1441
+ });
1442
+ };
1443
+
1444
+ /*
1445
+ Descarga de activos: los seleccionados, o todos si no hay selección — el
1446
+ mismo criterio que usa la descarga de imágenes.
1447
+
1448
+ Solo se descarga lo que ya está en S3. Un activo recién soltado vive como
1449
+ referencia al archivo del disco del usuario: "descargarlo" le devolvería el
1450
+ archivo que él mismo acaba de elegir, lo cual no tiene sentido.
1451
+ */
1452
+ const handleOnDownloadAssets = () => {
1453
+ const seleccion = state.selected_assets.length
1454
+ ? state.selected_assets
1455
+ : state.assets_values;
1456
+
1457
+ const descargables = seleccion.filter((asset) => asset.id && asset.url);
1458
+
1459
+ if (descargables.length === 0) {
1460
+ dispatch({
1461
+ type: "SET_MODAL",
1462
+ payload: {
1463
+ show: true,
1464
+ title: "Nada que descargar",
1465
+ message:
1466
+ "Los activos seleccionados todavía no se han guardado. Guárdalos primero.",
1467
+ image: warningIcon,
1468
+ },
1469
+ });
1470
+ return;
1471
+ }
1472
+
1473
+ descargables.forEach((asset) => saveAs(asset.url, asset.name));
1474
+ };
1475
+
1476
+ /*
1477
+ Borrado en lote de activos.
1478
+
1479
+ A diferencia de las imágenes, que se borran contra el backend en el momento,
1480
+ los activos se quitan de la bandeja y su baja viaja en el mismo PUT del
1481
+ guardado (`deleteAssets`). Es lo que permite al usuario deshacerse de un
1482
+ borrado por error: basta con no guardar. El mensaje lo dice, para que nadie
1483
+ se quede pensando que ya se aplicó.
1484
+ */
1485
+ const handleOnAskToDeleteAssets = () => {
1486
+ if (state.selected_assets.length === 0) {
1487
+ dispatch({
1488
+ type: "SET_MODAL",
1489
+ payload: {
1490
+ show: true,
1491
+ title: "Eliminar activos digitales",
1492
+ message: "No has seleccionado ningún activo para eliminar.",
1493
+ image: warningIcon,
1494
+ },
1495
+ });
1496
+ return;
1497
+ }
1498
+
1499
+ const seleccionados = state.selected_assets;
1500
+
1501
+ dispatch({
1502
+ type: "SET_MODAL",
1503
+ payload: {
1504
+ show: true,
1505
+ title: "Eliminar activos digitales",
1506
+ message: `Se quitarán ${seleccionados.length} activo(s) de la bandeja. El borrado se aplica al guardar.`,
1507
+ image: warningIcon,
1508
+ buttons: [
1509
+ {
1510
+ text: "Cancelar",
1511
+ buttonType: "general-white-button",
1512
+ action: () =>
1513
+ dispatch({ type: "SET_MODAL", payload: { show: false } }),
1514
+ },
1515
+ {
1516
+ text: "Eliminar",
1517
+ buttonType: "general-button-default",
1518
+ action: () => {
1519
+ dispatch({ type: "SET_MODAL", payload: { show: false } });
1520
+ seleccionados.forEach((asset) =>
1521
+ digitalAssets.removeAsset(asset),
1522
+ );
1523
+ },
1524
+ },
1525
+ ],
1526
+ },
1527
+ });
1528
+ };
1529
+
1530
+ const handleOnAskToDeleteImages = () => {
1531
+ if (currentTray === TRAYS.ASSETS) {
1532
+ handleOnAskToDeleteAssets();
1533
+ return;
1534
+ }
1535
+
1536
+ if (state.selected_images.length === 0) {
1537
+ dispatch({
1538
+ type: "SET_MODAL",
1539
+ payload: {
1540
+ show: true,
1541
+ title: "Eliminar imágenes",
1542
+ message: "No has seleccionado ninguna imagen para eliminar.",
1543
+ image: warningIcon,
1544
+ },
1545
+ });
1546
+ return;
1547
+ }
1548
+
1549
+ dispatch({
1550
+ type: "SET_MODAL",
1551
+ payload: {
1552
+ show: true,
1553
+ title: "Eliminar imágenes",
1554
+ message:
1555
+ "¿Estás seguro de que deseas eliminar las imágenes seleccionadas?",
1556
+ image: warningIcon,
1557
+ buttons: [
1558
+ {
1559
+ text: "Cancelar",
1560
+ buttonType: "general-white-button",
1561
+ action: () =>
1562
+ dispatch({ type: "SET_MODAL", payload: { show: false } }),
1563
+ },
1564
+ {
1565
+ text: "Eliminar",
1566
+ buttonType: "general-button-default",
1567
+ action: () => {
1568
+ dispatch({ type: "SET_MODAL", payload: { show: false } });
1569
+ deleteImages(token);
1570
+ },
1571
+ },
1572
+ ],
1573
+ },
1574
+ });
1575
+ };
1576
+
1577
+ const handleOnApproveSingleService = async () => {
1578
+ await sendSingleEvaluation("A");
1579
+ };
1580
+
1581
+ const handleOnRejectSingleService = () => {
1582
+ setShowRejectModal(true);
1583
+ };
1584
+
1585
+ const sendSingleEvaluation = async (result) => {
1586
+ if (state.saving) return;
1587
+
1588
+ dispatch({ type: "SET_SAVING", payload: true });
1589
+
1590
+ const concept = getConceptByTab(state.active_tab);
1591
+
1592
+ const articleId = state.product.id_article;
1593
+ const orderId = state.product.id_order ?? state.product.orderId;
1594
+ const sectionStatusKey = `${concept}_status`;
1595
+ const evalStatus = state.product[sectionStatusKey];
1596
+ const retailerId = state.active_retailer?.id_retailer;
1597
+
1598
+ let data = { articleId, orderId, concept, evalStatus, retailerId };
1599
+ let res;
1600
+ let message;
1601
+ let icon;
1602
+
1603
+ const activeTab = state.active_tab;
1604
+
1605
+ try {
1606
+ //Esto solo se ejecutará cuando se aprueba o rechaza desde el modal superior derecho
1607
+ if (result) {
1608
+ data.result = result;
1609
+ data.retailerId = retailerId;
1610
+
1611
+ res = await axios.put(
1612
+ `${process.env.REACT_APP_EVALUATION_ENDPOINT}`,
1613
+ data,
1614
+ {
1615
+ headers: {
1616
+ Authorization: token,
1617
+ },
1618
+ },
1619
+ );
1620
+
1621
+ const newStatuses = JSON.parse(res.data.body);
1622
+ const serviceStatus =
1623
+ newStatuses.newServiceStatus[articleId][`${concept}Status`];
1624
+ const articleStatus = newStatuses.newArticleStatus[articleId];
1625
+
1626
+
1627
+ // Actualizar el producto con los nuevos estados
1628
+ const updatedStatusByRetailer = state.product.statusByRetailer.map(
1629
+ (item) =>
1630
+ item.retailer_id === retailerId && item.service === concept
1631
+ ? { ...item, status: serviceStatus }
1632
+ : item,
1633
+ );
1634
+
1635
+ const updatedProduct = {
1636
+ ...state.product,
1637
+ statusByRetailer: updatedStatusByRetailer,
1638
+ status: getMinorStatusFromRetailer(updatedStatusByRetailer),
1639
+ };
1640
+
1641
+ dispatch({ type: "SET_PRODUCT", payload: updatedProduct });
1642
+
1643
+ // Mostrar modal de éxito
1644
+ dispatch({
1645
+ type: "SET_MODAL",
1646
+ payload: {
1647
+ show: true,
1648
+ title: "",
1649
+ message:
1650
+ result === "A" ? "Aprobado con éxito" : "Rechazado con éxito",
1651
+ image: successIcon,
1652
+ },
1653
+ });
1654
+ } else {
1655
+ //Caso del botón "Enviar evaluación"
1656
+
1657
+ //Se construye el mensaje y se actualiza el estatus
1658
+
1659
+ const specialistDone = ["RC", "RA", "CA"].includes(evalStatus);
1660
+
1661
+ if (specialistDone) {
1662
+ message = `${activeTab} enviada a facilitador`;
1663
+ icon =
1664
+ activeTab === "Descripción"
1665
+ ? descriptionSent
1666
+ : activeTab === "Ficha técnica"
1667
+ ? attributesSent
1668
+ : imagesSent;
1669
+ } else if (["IE", "AC", "RP", "RCA"].includes(evalStatus)) {
1670
+ message = "Evaluación enviada";
1671
+ icon = successIcon;
1672
+ }
1673
+
1674
+ res = await axios.put(`${process.env.REACT_APP_SEND_EVAL}`, data, {
1675
+ headers: {
1676
+ Authorization: token,
1677
+ },
1678
+ });
1679
+ }
1680
+
1681
+ if (res.data.statusCode === 200) {
1682
+ const { newStatus, newOrderStatus, newArticleStatus } = JSON.parse(
1683
+ res.data.body,
1684
+ );
1685
+
1686
+ const articleStatus = newArticleStatus[articleId];
1687
+
1688
+
1689
+ // const retailers = state.product.categoryRetailer.map((r) => ({
1690
+ // id: r.id_retailer,
1691
+ // name: r.retailerName,
1692
+ // }));
1693
+ // const messageToChat = createMessage(
1694
+ // retailers,
1695
+ // retailerId,
1696
+ // evalStatus,
1697
+ // newStatus,
1698
+ // activeTab,
1699
+ // );
1700
+
1701
+ // const messageData = {
1702
+ // paramsBody: {
1703
+ // id: articleId,
1704
+ // version: state.product.version,
1705
+ // items: [{ type: "status", value: messageToChat }],
1706
+ // retailerId: retailerId,
1707
+ // status: state.product.status,
1708
+ // },
1709
+ // paramsHeader: { Authorization: token },
1710
+ // };
1711
+ // await sendMessage(messageData);
1712
+
1713
+ const updatedStatusByRetailerForSend =
1714
+ state.product.statusByRetailer.map((item) => {
1715
+ if (item.retailer_id === retailerId && item.service === concept) {
1716
+ return { ...item, status: newStatus };
1717
+ }
1718
+ return item;
1719
+ });
1720
+
1721
+ const updatedProduct = {
1722
+ ...state.product,
1723
+ [`${concept}_status`]: newStatus,
1724
+ statusByRetailer: updatedStatusByRetailerForSend,
1725
+ status: getMinorStatusFromRetailer(updatedStatusByRetailerForSend),
1726
+ };
1727
+
1728
+ //Actualizamos el services_data en el servicio específico del retailer actual
1729
+
1730
+ const updatedServicesData = state.services_data?.map((service) => ({
1731
+ ...service,
1732
+ status:
1733
+ service?.id_retailer === retailerId && service?.service === concept
1734
+ ? newStatus
1735
+ : service?.status,
1736
+ }));
1737
+
1738
+ const sessionProduct = sessionStorage.getItem("productSelected");
1739
+
1740
+ if (sessionProduct) {
1741
+ const productInSession = JSON.parse(sessionProduct);
1742
+ const updatedProductInSession = {
1743
+ ...productInSession,
1744
+ [`${concept}_status`]: newStatus,
1745
+ statusByRetailer: state.product.statusByRetailer.map((item) => {
1746
+ if (item.retailer_id === retailerId && item.service === concept) {
1747
+ return { ...item, status: newStatus };
1748
+ }
1749
+ return item;
1750
+ }),
1751
+ };
1752
+ sessionStorage.setItem(
1753
+ "productSelected",
1754
+ JSON.stringify(updatedProductInSession),
1755
+ );
1756
+ }
1757
+
1758
+ if (message) {
1759
+ dispatch({
1760
+ type: "SET_MODAL",
1761
+ payload: {
1762
+ show: true,
1763
+ title: "",
1764
+ message: message,
1765
+ image: successIcon,
1766
+ },
1767
+ });
1768
+ }
1769
+
1770
+ dispatch({ type: "SET_PRODUCT", payload: updatedProduct });
1771
+ dispatch({ type: "SET_SERVICES_DATA", payload: updatedServicesData });
1772
+ }
1773
+ } catch (error) {
1774
+ console.error("Error sending evaluation:", error);
1775
+ dispatch({
1776
+ type: "SET_MODAL",
1777
+ payload: {
1778
+ show: true,
1779
+ title: "Error",
1780
+ message: "Hubo un error al procesar la evaluación",
1781
+ image: errorIcon,
1782
+ },
1783
+ });
1784
+ } finally {
1785
+ dispatch({ type: "SET_SAVING", payload: false });
1786
+ }
1787
+ };
1788
+
1789
+ const handleOnApproveAllServices = async () => {
1790
+ await sendBulkEvaluation("A");
1791
+ };
1792
+
1793
+ const handleOnRejectAllServices = () => {
1794
+ setRejectAll(true);
1795
+ setShowRejectModal(true);
1796
+ };
1797
+
1798
+ const sendBulkEvaluation = async (result) => {
1799
+ dispatch({ type: "SET_SAVING", payload: true });
1800
+ try {
1801
+ const evaluationArray = [];
1802
+
1803
+ const conceptArray = getConceptsByRole(user.id_role);
1804
+
1805
+ state.services_data?.forEach((ret, idx) => {
1806
+ const {
1807
+ service: retailer_service,
1808
+ id_retailer,
1809
+ status: status_retailer_service,
1810
+ } = ret;
1811
+
1812
+ const lastStatusLevel = status_retailer_service?.replace(/.*\//, "");
1813
+
1814
+ if (
1815
+ conceptArray.includes(retailer_service) &&
1816
+ statusArray?.includes(lastStatusLevel)
1817
+ ) {
1818
+ const data = {
1819
+ articleId: state.product.id_article,
1820
+ orderId: state.product.id_order,
1821
+ concept: retailer_service,
1822
+ result,
1823
+ evalStatus: status_retailer_service,
1824
+ retailerId: id_retailer,
1825
+ };
1826
+
1827
+ evaluationArray.push(
1828
+ axios.put(`${process.env.REACT_APP_EVALUATION_ENDPOINT}`, data, {
1829
+ headers: {
1830
+ Authorization: token,
1831
+ },
1832
+ }),
1833
+ );
1834
+ }
1835
+ });
1836
+
1837
+ await Promise.all(evaluationArray);
1838
+
1839
+ // Sin una sola petición no hubo evaluación. Seguir de largo es lo que
1840
+ // pintaba el estatus nuevo y anunciaba éxito mientras la base no se
1841
+ // enteraba: el cambio se veía en pantalla y no existía. Un no-op tiene
1842
+ // que decirlo, no fingir que funcionó.
1843
+ if (evaluationArray.length === 0) {
1844
+ dispatch({
1845
+ type: "SET_MODAL",
1846
+ payload: {
1847
+ show: true,
1848
+ title: "Error",
1849
+ message:
1850
+ "No hay servicios que puedas evaluar en el estatus actual.",
1851
+ image: errorIcon,
1852
+ },
1853
+ });
1854
+ return;
1855
+ }
1856
+
1857
+ const status = `${result}A`;
1858
+
1859
+ // Actualizar el statusByRetailer para todos los retailers y conceptos
1860
+ const updatedStatusByRetailer = state.product.statusByRetailer.map(
1861
+ (item) =>
1862
+ conceptArray.includes(item.service) ? { ...item, status } : item,
1863
+ );
1864
+
1865
+ // Actualizar el producto con los nuevos estados
1866
+ const updatedProduct = {
1867
+ ...state.product,
1868
+ article_status: status,
1869
+ status: getMinorStatusFromRetailer(updatedStatusByRetailer),
1870
+ datasheet_status:
1871
+ state.product.datasheet_status === "NA" ? "NA" : status,
1872
+ description_status:
1873
+ state.product.description_status === "NA" ? "NA" : status,
1874
+ images_status: state.product.images_status === "NA" ? "NA" : status,
1875
+ statusByRetailer: updatedStatusByRetailer,
1876
+ };
1877
+
1878
+ dispatch({
1879
+ type: "SET_PRODUCT",
1880
+ payload: updatedProduct,
1881
+ });
1882
+
1883
+ const updatedServicesData = state.services_data.map((service) =>
1884
+ conceptArray.includes(service.service)
1885
+ ? { ...service, status: status }
1886
+ : service,
1887
+ );
1888
+
1889
+ dispatch({
1890
+ type: "SET_SERVICES_DATA",
1891
+ payload: updatedServicesData,
1892
+ });
1893
+
1894
+ // sessionStorage.setItem(
1895
+ // "productSelected",
1896
+ // JSON.stringify(updatedProduct),
1897
+ // );
1898
+ // sessionStorage.setItem(
1899
+ // "productEdit",
1900
+ // JSON.stringify({
1901
+ // ArticleId: updatedProduct.id_article,
1902
+ // idCategory: updatedProduct.id_category,
1903
+ // product: updatedProduct,
1904
+ // }),
1905
+ // );
1906
+
1907
+ // Mostrar modal de éxito
1908
+ dispatch({
1909
+ type: "SET_MODAL",
1910
+ payload: {
1911
+ show: true,
1912
+ title: "",
1913
+ message:
1914
+ result === "A"
1915
+ ? "Todos los servicios aprobados con éxito"
1916
+ : "Todos los servicios rechazados con éxito",
1917
+ image: successIcon,
1918
+ },
1919
+ });
1920
+ } catch (error) {
1921
+ console.error("Error in bulk evaluation:", error);
1922
+ } finally {
1923
+ dispatch({ type: "SET_SAVING", payload: false });
1924
+ }
1925
+ };
1926
+
1927
+ const handleOnChangeAssignations = async (assignationType, assignationId) => {
1928
+ const concept = getConceptByTab(state.active_tab);
1929
+ const data = {
1930
+ articleList: [
1931
+ {
1932
+ orderId: state.product.id_order,
1933
+ articleId: state.product.id_article,
1934
+ },
1935
+ ],
1936
+ concept: concept,
1937
+ userId: assignationId,
1938
+ };
1939
+
1940
+ await axios({
1941
+ method: "post",
1942
+ url: process.env.REACT_APP_ASSIGNATIONS_ENDPOINT,
1943
+ data,
1944
+ headers: {
1945
+ Authorization: token,
1946
+ },
1947
+ });
1948
+
1949
+ const updatedProduct = {
1950
+ ...state.product,
1951
+ [`id_${concept}_${assignationType}`]: assignationId,
1952
+ };
1953
+
1954
+ dispatch({ type: "SET_PRODUCT", payload: updatedProduct });
1955
+ };
1956
+
1957
+ const handleOnSetAssignation = () => {
1958
+ console.log("handleOnSetAssignation - pendiente de implementar");
1959
+ };
1960
+
1961
+ const handleOnSetImages = () => {
1962
+ console.log("handleOnSetImages - pendiente de implementar");
1963
+ };
1964
+
1965
+ const handleOnSetCheckAll = (isChecked) => {
1966
+ dispatch({ type: "TOGGLE_CHECK_ALL_IMAGES", payload: isChecked });
1967
+ };
1968
+
1969
+ const handleOnSetSelectedImages = () => {
1970
+ /*
1971
+ Esta función no se utiliza actualmente.
1972
+
1973
+ En la versión anterior se usaban dos funciones distintas para el checkbox
1974
+ "Seleccionar todo":
1975
+ - Una para cambiar el valor booleano del checkbox.
1976
+ - Otra para actualizar el array de imágenes seleccionadas.
1977
+
1978
+ En la versión actual se unificó la lógica en una sola función,
1979
+ `handleOnSetCheckAll`, que recibe el valor `true` o `false` del checkbox
1980
+ "Seleccionar todo" y, a partir de este, agrega o elimina todas las imágenes
1981
+ del array de imágenes seleccionadas.
1982
+ */
1983
+ // Sin uso: la selección se maneja con handleOnSetCheckAll.
1984
+ };
1985
+ const handleOnSubmitComment = async (e) => {
1986
+ e.preventDefault();
1987
+ const commentText = document.querySelector(
1988
+ "#commentary-box .ql-container .ql-editor > p",
1989
+ ).innerHTML;
1990
+ try {
1991
+ await createComment(commentText, token);
1992
+
1993
+ dispatch({
1994
+ type: "SET_MODAL",
1995
+ payload: {
1996
+ show: true,
1997
+ title: "",
1998
+ message: "Comentario guardado con éxito",
1999
+ image: successIcon,
2000
+ },
2001
+ });
2002
+ } catch (error) {
2003
+ console.error("Error saving comment:", error);
2004
+ dispatch({
2005
+ type: "SET_MODAL",
2006
+ payload: {
2007
+ show: true,
2008
+ title: "Error",
2009
+ message: "Hubo un error al guardar el comentario",
2010
+ image: errorIcon,
2011
+ },
2012
+ });
2013
+ }
2014
+ };
2015
+
2016
+ const handleOnSendEvaluationToFacilitator = () => {
2017
+ sendSingleEvaluation();
2018
+ };
2019
+
2020
+ const isEvaluationFinished = (id_rol, tab, statusArray) => {
2021
+ const servicesData = state.services_data;
2022
+ const activeRetailer = state.active_retailer;
2023
+ const product = state.product;
2024
+ const concept = getConceptByTab(tab);
2025
+
2026
+ const servicesByTab = servicesData?.filter(
2027
+ (serv) => serv.service === concept,
2028
+ );
2029
+
2030
+ const statusBySelectedRetailerService = servicesByTab
2031
+ ?.find((serv) => serv.id_retailer === activeRetailer?.id_retailer)
2032
+ ?.status?.replace(/.*\//, "");
2033
+
2034
+ const currentProducStatus = product?.[`${concept}_status`]?.replace(
2035
+ /.*\//,
2036
+ "",
2037
+ );
2038
+
2039
+ const unvalidated = ["IE", "CA"].includes(currentProducStatus);
2040
+ const auditorUnvalidated = !["RA", "AA", "ACA", "AP"].includes(
2041
+ currentProducStatus,
2042
+ );
2043
+
2044
+ let result;
2045
+ switch (id_rol) {
2046
+ case 7:
2047
+ case 8:
2048
+ // AC se valida/rechaza desde el panel, no se reenvía con "Enviar evaluación".
2049
+ const canSendEvaluation =
2050
+ statusBySelectedRetailerService !== "AC" &&
2051
+ statusArray.includes(statusBySelectedRetailerService);
2052
+
2053
+ // const conditionTwo = statusArray.includes(product?.status);
2054
+
2055
+ // const conditionThree =
2056
+ // servicesByTab.filter((service) =>
2057
+ // statusArray.includes(service.status?.replace(/.*\//, "")),
2058
+ // ).length === servicesByTab.length;
2059
+
2060
+ // return (canSendEvaluation && conditionTwo) || conditionThree;
2061
+
2062
+ return canSendEvaluation;
2063
+
2064
+ case 4:
2065
+ case 5:
2066
+ const filteredSrv45 = servicesByTab?.filter((serv) =>
2067
+ statusArray.includes(serv?.status),
2068
+ );
2069
+ const condition45 =
2070
+ unvalidated &&
2071
+ ["CA", "IE"].includes(product?.status) &&
2072
+ filteredSrv45?.length === servicesByTab?.length;
2073
+ return condition45;
2074
+ case 6:
2075
+ const statusInArray = statusArray.includes(product?.status);
2076
+ const allSrvValid = srv?.every((serv) =>
2077
+ ["RA", "AA", "AP", "ACA"].includes(serv?.status),
2078
+ );
2079
+ const condition6 = statusInArray && allSrvValid && auditorUnvalidated;
2080
+ return condition6;
2081
+ default:
2082
+ return false;
2083
+ }
2084
+ };
2085
+
2086
+ const handleOnChangeProductVersion = (version) => {
2087
+ dispatch({
2088
+ type: "SET_PRODUCT_VERSION",
2089
+ payload: version,
2090
+ });
2091
+
2092
+ loadData(true, version);
2093
+ };
2094
+
2095
+ const handleRetryLoad = () => {
2096
+ dispatch({ type: "SET_ERRORS", payload: [] });
2097
+ if (state.active_retailer) {
2098
+ loadData();
2099
+ } else {
2100
+ initializeProduct();
2101
+ }
2102
+ };
2103
+
2104
+ return {
2105
+ isRetailer,
2106
+ digitalAssets,
2107
+ currentTray,
2108
+ assetsLoadedFor,
2109
+ handleOnSetSelectedAssets,
2110
+ handleOnSetAllAssetsChecked,
2111
+ initializeProduct,
2112
+ loadAuditableData,
2113
+ loadData,
2114
+ handleOnChangeCurrentImage,
2115
+ handleOnShowModalGallery,
2116
+ handleOnChangeActiveRetailer,
2117
+ handleOnChangeActiveTab,
2118
+ handleOnToggleImageLayout,
2119
+ handleOnToggleObservation,
2120
+ handleOnHideObservation,
2121
+ isAuditorAssigned,
2122
+ isAssignedAs,
2123
+ isUserAssignedToService,
2124
+ canShowSingleEvaluationButtons,
2125
+ canShowBulkEvaluationButtons,
2126
+ getStatusByCurrentServiceAndRetailer,
2127
+ handleOnSaveAssets,
2128
+ handleOnClickSaveImages,
2129
+ loadFamily,
2130
+ hijosDelProducto,
2131
+ esHijoDeOtro,
2132
+ puedeAgruparse,
2133
+ handleOnUnlinked,
2134
+ loadVariantGroup,
2135
+ handleOnLeaveGroup,
2136
+ openFamilyMember,
2137
+ handleOnLinkedVariants,
2138
+ handleOnRestoreInheritance,
2139
+ cambiosDeFichaTecnica,
2140
+ cambiosDeDescripciones,
2141
+ handleOnClickSave,
2142
+ handleOnSetUpdatedDescriptions,
2143
+ handleOnSetUpdatedDatasheets,
2144
+ handleOnDownloadAssets,
2145
+ handleOnAskToDeleteAssets,
2146
+ handleOnAskToDeleteImages,
2147
+ handleOnApproveSingleService,
2148
+ handleOnRejectSingleService,
2149
+ sendSingleEvaluation,
2150
+ handleOnApproveAllServices,
2151
+ handleOnRejectAllServices,
2152
+ sendBulkEvaluation,
2153
+ handleOnChangeAssignations,
2154
+ handleOnSetAssignation,
2155
+ handleOnSetImages,
2156
+ handleOnSetCheckAll,
2157
+ handleOnSetSelectedImages,
2158
+ handleOnSubmitComment,
2159
+ handleOnSendEvaluationToFacilitator,
2160
+ isEvaluationFinished,
2161
+ handleOnChangeProductVersion,
2162
+ handleRetryLoad,
2163
+ showVersionSelector,
2164
+ setShowVersionSelector,
2165
+ auditableVersion,
2166
+ setAuditableVersion,
2167
+ headerTop,
2168
+ setHeaderTop,
2169
+ shotThd,
2170
+ setShotThd,
2171
+ observation,
2172
+ setObservation,
2173
+ showRejectModal,
2174
+ setShowRejectModal,
2175
+ isObservationVisible,
2176
+ setObservationVisible,
2177
+ imageLayout,
2178
+ setImageLayout,
2179
+ crossComment,
2180
+ setCrossComment,
2181
+ statusArray,
2182
+ setStatusArray,
2183
+ rejectAll,
2184
+ setRejectAll,
2185
+ showLinkVariants,
2186
+ setShowLinkVariants,
2187
+ family,
2188
+ setFamily,
2189
+ showFamily,
2190
+ setShowFamily,
2191
+ loadingVariant,
2192
+ setLoadingVariant,
2193
+ variantGroup,
2194
+ setVariantGroup,
2195
+ showGroup,
2196
+ setShowGroup,
2197
+ showGroupModal,
2198
+ setShowGroupModal,
2199
+ porDesvincular,
2200
+ setPorDesvincular,
2201
+ avisoFamilia,
2202
+ setAvisoFamilia,
2203
+ pendingPropagation,
2204
+ setPendingPropagation,
2205
+ preparandoPropagacion,
2206
+ setPreparandoPropagacion,
2207
+ auditServices,
2208
+ setAuditServices,
2209
+ auditDatasheets,
2210
+ setAuditDatasheets,
2211
+ auditDescriptions,
2212
+ setAuditDescriptions,
2213
+ showModal,
2214
+ setShowModal,
2215
+ compare,
2216
+ setCompare,
2217
+ openChangeStatusModal,
2218
+ setOpenChangeStatusModal,
2219
+ assetNotices,
2220
+ setAssetNotices,
2221
+ activeTray,
2222
+ setActiveTray,
2223
+ state,
2224
+ dispatch,
2225
+ saveDatasheets,
2226
+ saveDescriptions,
2227
+ updateImages,
2228
+ deleteImages,
2229
+ saveAssets,
2230
+ registerUploadedAssets,
2231
+ handleOnDownloadImages,
2232
+ createComment,
2233
+ getRootProps,
2234
+ getInputProps,
2235
+ isDragActive,
2236
+ open,
2237
+ };
2238
+ };