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,19 @@
1
+ import { ProviderProductEditionProvider } from "../RetailerProductEdition/context/provider-product-edition.context";
2
+ import { CreatorsProductEditionView } from "./CreatorsProductEditionView";
3
+
4
+ /**
5
+ * Edición de producto de Creators, responsiva (celular, tablet y desktop).
6
+ *
7
+ * Reemplazo de `RetailerProductEdition` con los mismos props:
8
+ * `tabsSections`, `productSelected`, `user`, `token`, `location` y
9
+ * `enableDigitalAssets`. Comparte el contexto (reducer y guardados) con la vista
10
+ * original; la lógica del componente es una copia en
11
+ * `hooks/useCreatorsProductEdition.js`.
12
+ */
13
+ export const CreatorsProductEdition = (props) => {
14
+ return (
15
+ <ProviderProductEditionProvider>
16
+ <CreatorsProductEditionView {...props} />
17
+ </ProviderProductEditionProvider>
18
+ );
19
+ };
@@ -0,0 +1,357 @@
1
+ /*
2
+ Respuestas de la API para las historias de CreatorsProductEdition.
3
+
4
+ Capturadas de dev (producto 144814, orden 10421, versión 3) el 2026-09-28 y
5
+ recortadas: la lista de tomas de imagen se dejó en seis. Los nombres del
6
+ personal se reemplazaron por genéricos: esta librería se publica en npm.
7
+
8
+ Cada respuesta conserva la envoltura que devuelve su lambda —unas mandan
9
+ `{ statusCode, body: "<json>" }` y otras `{ data }` directo—, porque el código
10
+ de la vista depende de esa forma.
11
+ */
12
+
13
+ const ARTICLE_ID = 144814;
14
+ const VERSION = 3;
15
+
16
+ /** Envoltura de `integration: lambda`: el cuerpo viaja como cadena. */
17
+ const lambda = (body) => ({
18
+ statusCode: 200,
19
+ headers: { "Access-Control-Allow-Origin": "*" },
20
+ body: JSON.stringify(body),
21
+ });
22
+
23
+ const RETAILERS = {
24
+ 36: { id: 36, name: "Oxxo", country: "México", id_region: 1, is_active: 1, active: 1 },
25
+ 38: { id: 38, name: "Del Sol", country: "México", id_region: 1, is_active: 1, active: 1 },
26
+ };
27
+
28
+ const comment = (id, concept, message) => ({
29
+ id,
30
+ user_id: 425,
31
+ article_id: ARTICLE_ID,
32
+ order_id: 10421,
33
+ message,
34
+ timestamp: "2025-11-15T21:12:00.000Z",
35
+ completed: 0,
36
+ version_id: VERSION,
37
+ concept,
38
+ });
39
+
40
+ const descriptionInputs = [
41
+ {
42
+ id_version: VERSION,
43
+ id: 4592,
44
+ id_retailer: 38,
45
+ name: "Header Del Sol",
46
+ id_description_type: null,
47
+ description:
48
+ "Encabezado: Nombre genérico del producto + Marca + Sub Marca (Opcional) + Modelo / Estilo / Tipo / Talla / Color / Capacidad / Tamaño / Edad Mínimo 40 Máximo 70 Caracteres",
49
+ min_words: null,
50
+ max_words: null,
51
+ min_chars: 40,
52
+ max_chars: 70,
53
+ type: "Alfanumérico",
54
+ active: 1,
55
+ required: true,
56
+ value: "Yoghurt Danone Frutas Selectas con trozos de fresa 120 g",
57
+ ai_generated: false,
58
+ isOverridden: false,
59
+ inheritable: true,
60
+ },
61
+ {
62
+ id_version: VERSION,
63
+ id: 4593,
64
+ id_retailer: 38,
65
+ name: "Descripción Comercial del Producto Del Sol",
66
+ id_description_type: null,
67
+ description: "Descripción Comercial",
68
+ min_words: null,
69
+ max_words: null,
70
+ min_chars: null,
71
+ max_chars: 1200,
72
+ type: "Alfanumérico",
73
+ active: 1,
74
+ required: true,
75
+ value:
76
+ "Yoghurt batido con trozos de fresa, ahora con más fruta y una textura más cremosa. Ideal para el desayuno o como colación.",
77
+ ai_generated: false,
78
+ isOverridden: false,
79
+ inheritable: true,
80
+ },
81
+ ];
82
+
83
+ const service = (id_retailer, serviceName, status, task_user_group_id) => ({
84
+ id_order: 10421,
85
+ id_retailer,
86
+ id_article: ARTICLE_ID,
87
+ service: serviceName,
88
+ quantity: 1,
89
+ price: 0,
90
+ id_user: 1989,
91
+ datasheet_common: null,
92
+ discount: null,
93
+ status,
94
+ task_user_group_id,
95
+ is_billing: 0,
96
+ last_modified: "2025-11-15T21:10:26.000Z",
97
+ is_active: 1,
98
+ deleted_by: null,
99
+ deleted_at: null,
100
+ final_version_id: null,
101
+ retailerToProvider: null,
102
+ is_retailer: 0,
103
+ retailersId: null,
104
+ });
105
+
106
+ // Tomas de imagen: seis de las 36 que devuelve dev, con su orden por cadena.
107
+ const IMAGE_INPUTS = [
108
+ { id: 1, name: "Central (frente comercial)", required: 1 },
109
+ { id: 11, name: "Hero image", required: 0 },
110
+ { id: 3, name: "Trasera (back)", required: 0 },
111
+ { id: 2, name: "Fuera del empaque / Abierto interior", required: 0 },
112
+ { id: 12, name: "Tabla nutrimental / ingredientes", required: 0 },
113
+ { id: 15, name: "Zoom", required: 0 },
114
+ ].map((input) => ({
115
+ vertical_dpi: null,
116
+ horizontal_dpi: null,
117
+ vertical_px: null,
118
+ horizontal_px: null,
119
+ ...input,
120
+ }));
121
+
122
+ const imageValue = (id, id_image, src, size) => ({
123
+ id,
124
+ status: null,
125
+ id_image,
126
+ packing_type: null,
127
+ image_type: null,
128
+ id_article: ARTICLE_ID,
129
+ src: `id-${ARTICLE_ID}/${VERSION}/${src}`,
130
+ timestamp: "2026-02-11T19:12:38.000Z",
131
+ id_version: VERSION,
132
+ width: size,
133
+ height: size,
134
+ scene_type: null,
135
+ image_shot_type: null,
136
+ last_modified: "2026-08-25T14:31:27.000Z",
137
+ has_leyend: 0,
138
+ onb_status: 1,
139
+ onb_comments: "",
140
+ });
141
+
142
+ const upcAttribute = {
143
+ id: 683,
144
+ name: "UPC",
145
+ is_datasheet: 1,
146
+ is_new_attribute: 0,
147
+ last_modified_by: 275,
148
+ created_at: "2026-07-01T13:56:44.000Z",
149
+ updated_at: "2026-07-01T13:56:44.000Z",
150
+ is_active: 1,
151
+ value: "400080029609",
152
+ };
153
+
154
+ const users = (role, count) => ({
155
+ data: Array.from({ length: count }, (_, i) => ({
156
+ name: `${role} ${i + 1}`,
157
+ last_name: null,
158
+ id_user: 9000 + i,
159
+ })),
160
+ });
161
+
162
+ const member = (articleId, name, upc, isCurrent = false) => ({ articleId, name, upc, isCurrent });
163
+
164
+ const buildFamily = (familia) => {
165
+ if (familia === "padre") {
166
+ return {
167
+ articleId: ARTICLE_ID,
168
+ isVariant: false,
169
+ parent: null,
170
+ variants: [
171
+ member(144821, "Yoghurt Frutas Selectas durazno 120 g", "400080029630"),
172
+ member(144822, "Yoghurt Frutas Selectas mango 120 g con un nombre bastante largo", "400080029647"),
173
+ ],
174
+ };
175
+ }
176
+ if (familia === "hijo") {
177
+ return {
178
+ articleId: ARTICLE_ID,
179
+ isVariant: true,
180
+ parent: member(144800, "Yoghurt Frutas Selectas (línea)", "400080029500"),
181
+ variants: [
182
+ member(ARTICLE_ID, "Producto 300080", "400080029609", true),
183
+ member(144821, "Yoghurt Frutas Selectas durazno 120 g", "400080029630"),
184
+ ],
185
+ };
186
+ }
187
+ return { articleId: ARTICLE_ID, isVariant: false, parent: null, variants: [] };
188
+ };
189
+
190
+ /**
191
+ * Crea las respuestas.
192
+ * - `images: false` simula un producto sin imágenes.
193
+ * - `delSolStatus` cambia el estatus de los tres servicios de Del Sol; con
194
+ * "AC" el auditor asignado puede validar o rechazar.
195
+ * - `familia`: "padre" (este producto con dos hijos) o "hijo" (este producto
196
+ * es hijo de otro, con un hermano).
197
+ * - `grupo`: el producto está en un grupo de variantes con otros dos.
198
+ */
199
+ export const buildFixtures = ({ images = true, delSolStatus, familia, grupo = false } = {}) => ({
200
+ comments: {
201
+ description: lambda({ message: "OK", data: [comment(15886, "description", "Revisar la longitud del header.")], order: null }),
202
+ datasheet: lambda({ message: "OK", data: [comment(15885, "datasheet", "Rechazo")], order: null }),
203
+ images: lambda({ message: "OK", data: [], order: null }),
204
+ },
205
+ datasheets: lambda({
206
+ message: "ok",
207
+ data: {
208
+ 36: { retailer: RETAILERS[36], data: {} },
209
+ 38: {
210
+ retailer: RETAILERS[38],
211
+ data: { 6: { groupId: "6", dataGroup: "Información Básica del Producto", inputs: ["683_38"] } },
212
+ },
213
+ inputs: {
214
+ "683_38": {
215
+ id: 683,
216
+ name: "UPC",
217
+ min_chars: null,
218
+ max_chars: 14,
219
+ type: "Numérico",
220
+ id_group: 6,
221
+ required: true,
222
+ description: "Código de Barras del Producto, GTIN, UPC",
223
+ option_list: null,
224
+ value: "400080029609",
225
+ onb_status: 1,
226
+ onb_comments: null,
227
+ ai_generated: 0,
228
+ isOverridden: false,
229
+ inheritable: false,
230
+ active: 1,
231
+ commercial_model: null,
232
+ },
233
+ },
234
+ variant: { isVariant: false, parentArticleId: null, parent: null },
235
+ },
236
+ }),
237
+ descriptions: lambda({
238
+ message: "OK",
239
+ data: [
240
+ { ...RETAILERS[36], inputs: [] },
241
+ { ...RETAILERS[38], inputs: descriptionInputs },
242
+ ],
243
+ inputsGeneral: Object.fromEntries(descriptionInputs.map((input) => [input.id, input])),
244
+ variant: { isVariant: false, parentArticleId: null, parent: null },
245
+ }),
246
+ users: {
247
+ 4: { data: [{ name: "Coordinador Textos", last_name: null, id_user: 423 }] },
248
+ 5: { data: [{ name: "Coordinador Imágenes", last_name: null, id_user: 424 }] },
249
+ 6: { data: [{ name: "Auditor QA", last_name: null, id_user: 425 }, ...users("Auditor", 2).data] },
250
+ 7: { data: [{ name: "Especialista Textos", last_name: null, id_user: 426 }, ...users("Especialista textos", 3).data] },
251
+ 8: { data: [{ name: "Especialista Edición", last_name: null, id_user: 427 }, ...users("Especialista edición", 3).data] },
252
+ },
253
+ family: lambda(buildFamily(familia)),
254
+ group: lambda(
255
+ grupo
256
+ ? {
257
+ articleId: ARTICLE_ID,
258
+ group: "YOG-FRESA",
259
+ members: [
260
+ member(ARTICLE_ID, "Producto 300080", "400080029609", true),
261
+ member(144815, "Yoghurt Frutas Selectas fresa 4 pack", "400080029616"),
262
+ member(144816, "Yoghurt Frutas Selectas fresa 1 kg", "400080029623"),
263
+ ],
264
+ }
265
+ : { articleId: ARTICLE_ID, group: null, members: [] },
266
+ ),
267
+ services: lambda({
268
+ message: "OK",
269
+ data: [
270
+ service(36, "datasheet", "RA/AS", 2),
271
+ service(36, "description", "RA/AS", 2),
272
+ service(36, "images", "AA", 5),
273
+ service(38, "datasheet", delSolStatus ?? "RA/AS", 2),
274
+ service(38, "description", delSolStatus ?? "RA/AS", 2),
275
+ service(38, "images", delSolStatus ?? "AA", 5),
276
+ ],
277
+ }),
278
+ percentages: lambda([
279
+ {
280
+ id_article: ARTICLE_ID,
281
+ version: VERSION,
282
+ relations: [
283
+ {
284
+ id_retailer: 38,
285
+ id_category: 3513,
286
+ datasheet: { required: 100, general: 100 },
287
+ descriptions: { required: 100, general: 100 },
288
+ images: {
289
+ required: 100,
290
+ general: 80.01,
291
+ details: {
292
+ mandatoryShots: { total: 1, completed: 1, percentage: 100 },
293
+ optionalShots: { total: 5, completed: 2, percentage: 40 },
294
+ namingAttributes: { total: 1, completed: 1, percentage: 100 },
295
+ },
296
+ },
297
+ percentagesGeneral: { required: 100, general: 69.43 },
298
+ },
299
+ ],
300
+ summary: { totalRelations: 1, averageRequired: 100, averageGeneral: 69.43 },
301
+ },
302
+ ]),
303
+ images: lambda({
304
+ message: "OK",
305
+ data: {
306
+ retailers: [{ ...RETAILERS[38], min: 2, max: 5 }],
307
+ attrForImgs: { 38: [upcAttribute], general: [upcAttribute] },
308
+ imagePackagingType: [
309
+ { id: 1, name: "Embalaje adicional", deliverable_value: "0", status: 1 },
310
+ { id: 2, name: "En paquete", deliverable_value: "1", status: 1 },
311
+ { id: 3, name: "Producto expuesto", deliverable_value: "2", status: 1 },
312
+ { id: 4, name: "Embalaje multiforme", deliverable_value: "3", status: 1 },
313
+ { id: 5, name: "Si no es una imagen / no corresponde", deliverable_value: "X", status: 1 },
314
+ ],
315
+ imageType: [
316
+ { id: 1, name: "Fotografía del producto", deliverable_value: "A", status: 1 },
317
+ { id: 2, name: "Render", deliverable_value: "R", status: 1 },
318
+ ],
319
+ imageShotType: [
320
+ { id: 1, type_shot: "SKU", status: 1 },
321
+ { id: 2, type_shot: "SKU.1", status: 1 },
322
+ { id: 3, type_shot: "SKU.2", status: 1 },
323
+ ],
324
+ inputs: IMAGE_INPUTS,
325
+ retailerMandatories: [
326
+ [{ id_retailer: 38, id_category: 3506, id_image: 1, order: 1, priority: 1, mandatory: 1 }],
327
+ ],
328
+ inputsByRetailer: [
329
+ IMAGE_INPUTS.map((input, index) => ({
330
+ id_retailer: 38,
331
+ id_category: 3513,
332
+ id_image: input.id,
333
+ order: index + 1,
334
+ priority: 1,
335
+ mandatory: input.required,
336
+ })),
337
+ ],
338
+ values: images
339
+ ? [
340
+ imageValue(1070155, 1, "1-4f5e3461-395f-4a66-b676-c0a4fb9a3069.jpg", 250),
341
+ imageValue(1070366, 11, "11-b522d019-e845-4921-b28f-5ce4e3c135ba.jpg", 3000),
342
+ imageValue(1070367, 3, "3-37a15352-c6e9-47db-a0fa-ca277c571b1c.jpg", 3000),
343
+ ]
344
+ : [],
345
+ assets: [],
346
+ },
347
+ retailerWithoutInputs: [],
348
+ }),
349
+ // Historial de versiones del producto (`REACT_APP_VERSIONS_ENDPOINT`).
350
+ versions: lambda({
351
+ data: [
352
+ { version: 1, article_status: null, timestamp: null },
353
+ { version: 2, article_status: "AP", timestamp: "2026-05-14T17:20:00.000Z" },
354
+ { version: VERSION, article_status: null, timestamp: "2026-08-02T15:05:00.000Z" },
355
+ ],
356
+ }),
357
+ });
@@ -0,0 +1,144 @@
1
+ import { useEffect, useState } from "react";
2
+ import axios from "axios";
3
+
4
+ import { buildFixtures } from "./api.fixtures";
5
+
6
+ /*
7
+ API falsa para las historias: reemplaza el adaptador de axios mientras la
8
+ historia está montada. Toda la pantalla (contexto, utils, modales) usa la
9
+ instancia por omisión de axios y nadie usa `fetch`, así que con esto basta y
10
+ no hace falta ninguna dependencia nueva.
11
+
12
+ Lo que no reconoce responde 200 vacío y lo avisa en la consola: así una
13
+ llamada nueva no rompe la historia y se nota qué falta simular.
14
+ */
15
+
16
+ const reply = (config, data, status = 200) =>
17
+ new Promise((resolve) =>
18
+ // Un poco de latencia para ver el skeleton y los estados de carga.
19
+ setTimeout(
20
+ () => resolve({ data, status, statusText: "OK", headers: {}, config, request: {} }),
21
+ 150,
22
+ ),
23
+ );
24
+
25
+ const pathOf = (url = "") => {
26
+ try {
27
+ const { pathname, searchParams } = new URL(url, "http://mock");
28
+ // `/dev/articles-data/images` → `articles-data/images`
29
+ return { path: pathname.replace(/^\/[^/]+\//, ""), params: searchParams };
30
+ } catch {
31
+ return { path: url, params: new URLSearchParams() };
32
+ }
33
+ };
34
+
35
+ const ok = { statusCode: 200, body: JSON.stringify({ message: "OK" }) };
36
+
37
+ export const createMockAdapter = ({
38
+ images = true,
39
+ failLoad = false,
40
+ delSolStatus,
41
+ familia,
42
+ grupo,
43
+ } = {}) => {
44
+ const fx = buildFixtures({ images, delSolStatus, familia, grupo });
45
+
46
+ return (config) => {
47
+ const method = (config.method || "get").toUpperCase();
48
+ const { path, params } = pathOf(config.url);
49
+ const query = { ...Object.fromEntries(params), ...(config.params || {}) };
50
+
51
+ if (failLoad && path === "services") {
52
+ return reply(config, { statusCode: 500, body: JSON.stringify({ message: "Error simulado" }) }, 500).then(
53
+ (response) => Promise.reject(Object.assign(new Error("Request failed with status code 500"), { response })),
54
+ );
55
+ }
56
+
57
+ if (method === "GET" && path === "comments") return reply(config, fx.comments[query.concept] ?? fx.comments.images);
58
+ if (path === "articles-data/datasheets") return reply(config, fx.datasheets);
59
+ if (method === "GET" && path === "articles-data/descriptions") return reply(config, fx.descriptions);
60
+ if (path === "articles-data/images") return reply(config, fx.images);
61
+ if (method === "GET" && path === "users") return reply(config, fx.users[query.fetchRole] ?? { data: [] });
62
+ if (path === "variants/family") return reply(config, fx.family);
63
+ if (path === "variants/group") return reply(config, fx.group);
64
+ if (method === "GET" && path === "services") return reply(config, fx.services);
65
+ if (method === "GET" && path === "versions") return reply(config, fx.versions);
66
+ if (path === "percentages") return reply(config, fx.percentages);
67
+ // Sin versión auditable ni observación: con la API real de dev estas dos
68
+ // fallan por CORS, así que la pantalla ya sabe vivir sin ellas.
69
+ if (method === "GET" && path === "version-manager") {
70
+ return reply(config, { statusCode: 200, body: JSON.stringify({ data: null }) });
71
+ }
72
+ if (method === "GET" && path === "articles-observations/read-observation") {
73
+ return reply(config, { statusCode: 200, body: JSON.stringify({ data: [{ observations: "" }] }) });
74
+ }
75
+
76
+ // Guardados, evaluaciones y demás escrituras: se aceptan sin hacer nada.
77
+ if (method !== "GET") {
78
+ console.info(`[mock API] ${method} ${path}`, config.data);
79
+ return reply(config, ok);
80
+ }
81
+
82
+ console.warn(`[mock API] sin simular: ${method} ${path}`, query);
83
+ return reply(config, { statusCode: 200, body: JSON.stringify({ message: "OK", data: [] }) });
84
+ };
85
+ };
86
+
87
+ const realAdapter = () => {
88
+ if (!axios.defaults.__realAdapter) axios.defaults.__realAdapter = axios.defaults.adapter;
89
+ return axios.defaults.__realAdapter;
90
+ };
91
+
92
+ let activeMock = null;
93
+
94
+ /*
95
+ Monta la API falsa ANTES que la pantalla: el adaptador se cambia durante el
96
+ render, y los efectos de los hijos —que son los que piden los datos— corren
97
+ después.
98
+
99
+ Dos detalles que no son adorno:
100
+ - Se aplica en CADA render, no solo al montar. Al cambiar de historia desde
101
+ el menú, React reutiliza este mismo componente con otras `options` (por
102
+ ejemplo, de "Error al cargar" a otra): si solo se aplicara al montar, la
103
+ historia nueva seguiría con la API de la anterior.
104
+ - Al desmontarse solo restaura si nadie más tomó el relevo. El render de la
105
+ historia nueva ocurre ANTES que la limpieza de la vieja; restaurar a ciegas
106
+ le quitaría la API falsa a la historia que acaba de entrar.
107
+ */
108
+ const MockApi = ({ options, children }) => {
109
+ const [id] = useState(() => Symbol("mock-api"));
110
+ const key = JSON.stringify(options ?? {});
111
+ realAdapter();
112
+ if (activeMock?.id !== id || activeMock.key !== key) {
113
+ axios.defaults.adapter = createMockAdapter(options);
114
+ activeMock = { id, key };
115
+ }
116
+ useEffect(
117
+ () => () => {
118
+ if (activeMock?.id === id) {
119
+ axios.defaults.adapter = realAdapter();
120
+ activeMock = null;
121
+ }
122
+ },
123
+ [id],
124
+ );
125
+ return children;
126
+ };
127
+
128
+ /**
129
+ * Decorador de Storybook que envuelve la historia en la API falsa. La `key`
130
+ * obliga a remontar la pantalla cuando cambian las opciones, para que vuelva a
131
+ * pedir los datos con la API nueva.
132
+ */
133
+ export const withMockApi = (options) => (Story) => (
134
+ <MockApi key={JSON.stringify(options ?? {})} options={options}>
135
+ <Story />
136
+ </MockApi>
137
+ );
138
+
139
+ /** Decorador para las historias contra dev: garantiza el adaptador real. */
140
+ export const withRealApi = (Story) => {
141
+ axios.defaults.adapter = realAdapter();
142
+ activeMock = null;
143
+ return <Story />;
144
+ };
@@ -0,0 +1,183 @@
1
+ import { useState } from "react";
2
+ import { Box, Button, Paper, Stack, TextField, Typography } from "@mui/material";
3
+
4
+ import successIcon from "../../../../assets/images/genericModal/genericModalCheck.svg";
5
+ import errorIcon from "../../../../assets/images/genericModal/errorModal.svg";
6
+
7
+ import { COLORS, FONTS } from "./tokens";
8
+
9
+ /*
10
+ Caja de comentario y botones de acción ("Cambio de Estatus", "Enviar
11
+ evaluación").
12
+
13
+ Reemplaza a TagAndInput (Quill) + Commentary + los botones de la vista
14
+ original. El comentario se guarda con el mismo `createComment` y los mismos
15
+ avisos que `handleOnSubmitComment`, pero leyendo el campo controlado en lugar
16
+ del DOM de Quill. Con eso se va un defecto del original: solo guardaba el
17
+ primer párrafo (`.ql-editor > p`), así que un comentario de dos párrafos
18
+ perdía el segundo.
19
+ */
20
+
21
+ const MAX_CHARS = 999;
22
+
23
+ /*
24
+ Se guarda el texto tal cual, con sus saltos de línea: los comentarios se
25
+ pintan como texto (`white-space: pre-wrap`), no como HTML, y es el mismo
26
+ formato que ya usa el mensaje de rechazo (convierte los <br> de Quill a \n).
27
+ */
28
+ const toCommentText = (text) => text.trim();
29
+
30
+ const pill = {
31
+ borderRadius: "30px",
32
+ px: "25px",
33
+ py: "10px",
34
+ minHeight: 40,
35
+ textTransform: "none",
36
+ fontSize: 18,
37
+ lineHeight: "22px",
38
+ boxShadow: "none",
39
+ whiteSpace: "nowrap",
40
+ "&:hover": { boxShadow: "none", opacity: 0.7 },
41
+ };
42
+
43
+ export const CommentBox = ({
44
+ comment,
45
+ crossComment,
46
+ createComment,
47
+ token,
48
+ dispatch,
49
+ showChangeStatus,
50
+ onOpenChangeStatus,
51
+ showSendEvaluation,
52
+ canSendEvaluation,
53
+ onSendEvaluation,
54
+ }) => {
55
+ const [text, setText] = useState("");
56
+ const [sending, setSending] = useState(false);
57
+
58
+ const notify = (payload) => dispatch({ type: "SET_MODAL", payload: { show: true, ...payload } });
59
+
60
+ const submit = async () => {
61
+ if (!text.trim()) return;
62
+ setSending(true);
63
+ try {
64
+ await createComment(toCommentText(text), token);
65
+ setText("");
66
+ notify({ title: "", message: "Comentario guardado con éxito", image: successIcon });
67
+ } catch (error) {
68
+ console.error("Error saving comment:", error);
69
+ notify({ title: "Error", message: "Hubo un error al guardar el comentario", image: errorIcon });
70
+ } finally {
71
+ setSending(false);
72
+ }
73
+ };
74
+
75
+ return (
76
+ <Stack
77
+ direction={{ xs: "column", sm: "row" }}
78
+ alignItems={{ xs: "stretch", sm: "flex-end" }}
79
+ justifyContent="space-between"
80
+ flexWrap="wrap"
81
+ gap={1.5}
82
+ sx={{ py: "10px", minWidth: 0 }}
83
+ >
84
+ {!comment ? (
85
+ <Stack
86
+ direction={{ xs: "column", md: "row" }}
87
+ alignItems={{ xs: "stretch", md: "flex-end" }}
88
+ gap={1}
89
+ sx={{ flex: 1, minWidth: 0 }}
90
+ >
91
+ <Box sx={{ width: { xs: "100%", md: "min(500px, 100%)" } }}>
92
+ <Typography
93
+ component="label"
94
+ htmlFor="commentary-box-input"
95
+ sx={{ display: "block", mb: 0.5, fontFamily: FONTS.Raleway, fontSize: 14, color: COLORS.color_gray }}
96
+ >
97
+ Caja de Comentario
98
+ </Typography>
99
+ <TextField
100
+ id="commentary-box-input"
101
+ multiline
102
+ minRows={4}
103
+ maxRows={8}
104
+ fullWidth
105
+ value={text}
106
+ onChange={(e) => setText(e.target.value.slice(0, MAX_CHARS))}
107
+ helperText={`${text.length}/${MAX_CHARS}`}
108
+ FormHelperTextProps={{ sx: { textAlign: "right", mx: 0 } }}
109
+ InputProps={{ sx: { fontFamily: FONTS.AvenirNext, fontSize: 13, borderRadius: "3px" } }}
110
+ />
111
+ </Box>
112
+ <Button
113
+ variant="outlined"
114
+ disabled={!text.trim() || sending}
115
+ onClick={submit}
116
+ sx={{
117
+ ...pill,
118
+ mb: { md: "23px" },
119
+ fontSize: 16,
120
+ borderColor: COLORS.s5,
121
+ color: COLORS.s5,
122
+ "&:hover": { ...pill["&:hover"], borderColor: COLORS.s5 },
123
+ }}
124
+ >
125
+ {sending ? "Enviando…" : "Enviar comentario"}
126
+ </Button>
127
+ </Stack>
128
+ ) : (
129
+ <Paper
130
+ variant="outlined"
131
+ sx={{ flex: 1, minWidth: 0, maxWidth: { md: 500 }, minHeight: 80, p: "10px", borderColor: COLORS.s2 }}
132
+ >
133
+ <Typography
134
+ sx={{
135
+ whiteSpace: "pre-wrap",
136
+ overflowWrap: "anywhere",
137
+ fontFamily: FONTS.AvenirNext,
138
+ fontSize: 12,
139
+ lineHeight: "15px",
140
+ color: COLORS.deep_gray,
141
+ textDecoration: crossComment ? "line-through" : "none",
142
+ }}
143
+ >
144
+ {comment?.message}
145
+ </Typography>
146
+ </Paper>
147
+ )}
148
+
149
+ {(showChangeStatus || showSendEvaluation) && (
150
+ <Stack gap={2} sx={{ flexShrink: 0 }}>
151
+ {showChangeStatus && (
152
+ <Button
153
+ id="button-change-status"
154
+ variant="contained"
155
+ onClick={onOpenChangeStatus}
156
+ sx={{ ...pill, bgcolor: "#E33AA9", "&:hover": { ...pill["&:hover"], bgcolor: "#E33AA9" } }}
157
+ >
158
+ Cambio de Estatus
159
+ </Button>
160
+ )}
161
+ {showSendEvaluation && (
162
+ // Igual que el original: gris y deshabilitado mientras no se pueda
163
+ // enviar (evaluación sin terminar o faltan requeridos).
164
+ <Button
165
+ variant="contained"
166
+ disabled={!canSendEvaluation}
167
+ onClick={onSendEvaluation}
168
+ sx={{
169
+ ...pill,
170
+ bgcolor: "#71de56",
171
+ color: COLORS.white,
172
+ "&:hover": { ...pill["&:hover"], bgcolor: "#71de56" },
173
+ "&.Mui-disabled": { bgcolor: "grey", color: COLORS.white },
174
+ }}
175
+ >
176
+ Enviar evaluación
177
+ </Button>
178
+ )}
179
+ </Stack>
180
+ )}
181
+ </Stack>
182
+ );
183
+ };