contentoh-components-library 21.6.46 → 21.6.49

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.
@@ -7,9 +7,12 @@ import {
7
7
  Panel,
8
8
  Header,
9
9
  Body,
10
+ Step,
10
11
  SearchRow,
11
12
  List,
13
+ GhostButton,
12
14
  NewGroup,
15
+ Candidates,
13
16
  Message,
14
17
  Footer,
15
18
  } from "./styles";
@@ -23,9 +26,18 @@ import {
23
26
  un código en `article.variant_group` y ya. Por eso el modal no pregunta qué
24
27
  heredar: no hay nada que heredar.
25
28
 
26
- Dos formas de agrupar, y son la misma operación por debajo:
27
- - elegir un grupo que ya existe
28
- - teclear un código nuevo, que crea el grupo con este producto dentro
29
+ El modal tiene dos pasos y el segundo depende del primero:
30
+
31
+ 1. QUÉ GRUPO. Se elige uno de los que ya existen, o se crea uno nuevo. El
32
+ campo para crearlo está escondido detrás de un botón a propósito: abrirlo
33
+ de entrada le contesta al usuario una pregunta que todavía no se ha
34
+ hecho —si quiere buscar o crear— y empuja a teclear un código que
35
+ probablemente ya existe.
36
+
37
+ 2. QUÉ PRODUCTOS. Aparece solo cuando ya hay un grupo. Permite sumar varios
38
+ de una vez sin salir de aquí; antes había que volver a la tabla, entrar a
39
+ cada producto y repetir el trámite, que para un grupo de cinco eran cinco
40
+ vueltas completas.
29
41
 
30
42
  No hay tabla de grupos: un grupo existe mientras algún producto use su código.
31
43
  Eso hace que una errata no falle, solo cree un grupo de un miembro — de ahí que
@@ -57,6 +69,15 @@ export const VariantGroupModal = ({
57
69
  // Qué eligió el usuario: un código de la lista, o el que está escribiendo.
58
70
  const [selected, setSelected] = useState(null);
59
71
  const [nuevo, setNuevo] = useState("");
72
+ // El creador arranca cerrado: que esté abierto ya es una sugerencia de crear.
73
+ const [creando, setCreando] = useState(false);
74
+
75
+ // Paso 2: productos que se van a sumar junto con el de origen.
76
+ const [busquedaProd, setBusquedaProd] = useState("");
77
+ const [candidatos, setCandidatos] = useState([]);
78
+ const [totalCandidatos, setTotalCandidatos] = useState(0);
79
+ const [cargandoProd, setCargandoProd] = useState(false);
80
+ const [elegidos, setElegidos] = useState([]);
60
81
 
61
82
  const request = useCallback(
62
83
  async (action, body) => {
@@ -69,6 +90,11 @@ export const VariantGroupModal = ({
69
90
  [endpoint, token]
70
91
  );
71
92
 
93
+ const mensajeDe = (err, porDefecto) =>
94
+ err instanceof ApiError || err?.name === "ApiError"
95
+ ? err.message
96
+ : porDefecto;
97
+
72
98
  const buscar = useCallback(
73
99
  async (texto) => {
74
100
  setLoading(true);
@@ -77,11 +103,7 @@ export const VariantGroupModal = ({
77
103
  const data = await request("groups", { articleId, query: texto ?? "" });
78
104
  setGroups(data.groups ?? []);
79
105
  } catch (err) {
80
- setError(
81
- err instanceof ApiError || err?.name === "ApiError"
82
- ? err.message
83
- : "No se pudieron cargar los grupos."
84
- );
106
+ setError(mensajeDe(err, "No se pudieron cargar los grupos."));
85
107
  } finally {
86
108
  setLoading(false);
87
109
  }
@@ -108,34 +130,95 @@ export const VariantGroupModal = ({
108
130
  const codigoNuevo = normalizar(nuevo);
109
131
  const codigoFinal = selected ?? codigoNuevo;
110
132
  const esNuevo = !selected && !!codigoNuevo;
111
- const yaEstaba = codigoFinal && codigoFinal === currentGroup;
133
+ const yaEstaba = !!codigoFinal && codigoFinal === currentGroup;
134
+
135
+ const buscarProductos = useCallback(
136
+ async (texto) => {
137
+ setCargandoProd(true);
138
+ setError(null);
139
+ try {
140
+ const data = await request("group-candidates", {
141
+ articleId,
142
+ code: codigoFinal || "",
143
+ query: texto ?? "",
144
+ });
145
+ setCandidatos(data.candidates ?? []);
146
+ setTotalCandidatos(Number(data.total ?? 0));
147
+ } catch (err) {
148
+ setError(mensajeDe(err, "No se pudieron cargar los productos."));
149
+ } finally {
150
+ setCargandoProd(false);
151
+ }
152
+ },
153
+ [request, articleId, codigoFinal]
154
+ );
155
+
156
+ /*
157
+ La lista se recarga cuando cambia el código porque el servidor excluye a los
158
+ que ya están dentro: con otro grupo, el conjunto de candidatos es otro. Se
159
+ espera un momento porque el código nuevo se teclea letra por letra y sin la
160
+ pausa saldría una consulta por pulsación.
161
+ */
162
+ useEffect(() => {
163
+ if (!codigoFinal) {
164
+ setCandidatos([]);
165
+ setTotalCandidatos(0);
166
+ return undefined;
167
+ }
168
+ const t = setTimeout(() => buscarProductos(busquedaProd), 300);
169
+ return () => clearTimeout(t);
170
+ // eslint-disable-next-line react-hooks/exhaustive-deps
171
+ }, [codigoFinal]);
172
+
173
+ const alternar = (id) =>
174
+ setElegidos((previos) =>
175
+ previos.includes(id)
176
+ ? previos.filter((x) => x !== id)
177
+ : [...previos, id]
178
+ );
112
179
 
113
180
  const guardar = async () => {
114
- if (!codigoFinal || yaEstaba) return;
181
+ if (!codigoFinal) return;
182
+ // Sin código nuevo y sin productos que sumar no hay nada que hacer.
183
+ if (yaEstaba && elegidos.length === 0) return;
115
184
 
116
185
  setSaving(true);
117
186
  setError(null);
118
187
  try {
119
- const data = await request("join", { articleId, code: codigoFinal });
188
+ const data = await request("join", {
189
+ articleId,
190
+ code: codigoFinal,
191
+ alsoArticleIds: elegidos,
192
+ });
120
193
  onSaved?.({ ...data, group: data.group ?? codigoFinal });
121
194
  onClose?.();
122
195
  } catch (err) {
123
- setError(
124
- err instanceof ApiError || err?.name === "ApiError"
125
- ? err.message
126
- : "No se pudo agrupar el producto. Intenta de nuevo."
127
- );
196
+ setError(mensajeDe(err, "No se pudo agrupar el producto. Intenta de nuevo."));
128
197
  setSaving(false);
129
198
  }
130
199
  };
131
200
 
201
+ // Cuántos de los elegidos cambian de grupo, para avisarlo antes y no después.
202
+ const seMueven = candidatos.filter(
203
+ (c) => elegidos.includes(c.articleId) && c.currentGroup && c.currentGroup !== codigoFinal
204
+ ).length;
205
+
132
206
  const resumen = () => {
133
- if (yaEstaba) return `Este producto ya está en «${codigoFinal}».`;
134
- if (esNuevo) return `Se creará el grupo «${codigoNuevo}» con este producto.`;
135
- if (selected) return `Se agregará a «${selected}».`;
136
- return "Elige un grupo o escribe un código nuevo.";
207
+ if (!codigoFinal) return "Elige un grupo o crea uno nuevo.";
208
+
209
+ const sufijo = elegidos.length
210
+ ? ` y ${elegidos.length} producto${elegidos.length === 1 ? "" : "s"} más`
211
+ : "";
212
+
213
+ if (yaEstaba && !elegidos.length) return `Este producto ya está en «${codigoFinal}».`;
214
+ if (yaEstaba) return `Se agregarán ${elegidos.length} producto${elegidos.length === 1 ? "" : "s"} a «${codigoFinal}».`;
215
+ if (esNuevo) return `Se creará «${codigoNuevo}» con este producto${sufijo}.`;
216
+ return `Se agregará a «${selected}»${sufijo}.`;
137
217
  };
138
218
 
219
+ const puedeGuardar =
220
+ !saving && !!codigoFinal && (!yaEstaba || elegidos.length > 0);
221
+
139
222
  return (
140
223
  <Overlay onClick={saving ? undefined : onClose}>
141
224
  <Panel onClick={(event) => event.stopPropagation()}>
@@ -151,6 +234,10 @@ export const VariantGroupModal = ({
151
234
  <Body>
152
235
  {error && <Message className="error">{error}</Message>}
153
236
 
237
+ <Step>
238
+ <h3>1 · Elige el grupo</h3>
239
+ </Step>
240
+
154
241
  <SearchRow
155
242
  onSubmit={(event) => {
156
243
  event.preventDefault();
@@ -180,6 +267,7 @@ export const VariantGroupModal = ({
180
267
  onChange={() => {
181
268
  setSelected(grupo.code);
182
269
  setNuevo("");
270
+ setCreando(false);
183
271
  }}
184
272
  />
185
273
  <span className="codigo">{grupo.code}</span>
@@ -201,28 +289,137 @@ export const VariantGroupModal = ({
201
289
  <Message>
202
290
  {query.trim()
203
291
  ? "Ningún grupo coincide con esa búsqueda."
204
- : "Todavía no hay grupos en esta compañía. Crea el primero abajo."}
292
+ : "Todavía no hay grupos en esta compañía."}
205
293
  </Message>
206
294
  )}
207
295
 
208
- <NewGroup>
209
- <label htmlFor="nuevo-grupo">O crea un grupo nuevo</label>
210
- <input
211
- id="nuevo-grupo"
212
- type="text"
213
- value={nuevo}
214
- placeholder="RELOJ-CASIO-A158"
215
- maxLength={50}
216
- onChange={(event) => {
217
- setNuevo(event.target.value);
296
+ {/*
297
+ El campo para crear vive detrás de este botón. Mostrarlo de entrada
298
+ daba por hecho que el usuario venía a crear, cuando la mayoría de las
299
+ veces lo que quiere es sumarse a un grupo que ya existe.
300
+ */}
301
+ {!creando ? (
302
+ <GhostButton
303
+ type="button"
304
+ onClick={() => {
305
+ setCreando(true);
218
306
  setSelected(null);
219
307
  }}
220
- />
221
- <span className="ayuda">
222
- Letras, números, guion y guion bajo. Se guarda en mayúsculas, así
223
- que «abc-1» y «ABC-1» son el mismo grupo.
224
- </span>
225
- </NewGroup>
308
+ >
309
+ + Crear un grupo nuevo
310
+ </GhostButton>
311
+ ) : (
312
+ <NewGroup>
313
+ <div className="encabezado">
314
+ <label htmlFor="nuevo-grupo">Código del grupo nuevo</label>
315
+ <button
316
+ type="button"
317
+ onClick={() => {
318
+ setCreando(false);
319
+ setNuevo("");
320
+ }}
321
+ >
322
+ Cancelar
323
+ </button>
324
+ </div>
325
+ <input
326
+ id="nuevo-grupo"
327
+ type="text"
328
+ value={nuevo}
329
+ placeholder="RELOJ-CASIO-A158"
330
+ maxLength={50}
331
+ autoFocus
332
+ onChange={(event) => {
333
+ setNuevo(event.target.value);
334
+ setSelected(null);
335
+ }}
336
+ />
337
+ <span className="ayuda">
338
+ Letras, números, guion y guion bajo. Se guarda en mayúsculas, así
339
+ que «abc-1» y «ABC-1» son el mismo grupo.
340
+ </span>
341
+ </NewGroup>
342
+ )}
343
+
344
+ {/*
345
+ Paso 2. Solo tiene sentido con un grupo ya elegido: sin código no hay
346
+ a dónde sumar, y el servidor necesita el código para excluir a los
347
+ que ya están dentro.
348
+ */}
349
+ {!!codigoFinal && (
350
+ <>
351
+ <Step className="segundo">
352
+ <h3>2 · Agrega más productos a «{codigoFinal}»</h3>
353
+ <p>Opcional. Evita entrar a cada producto por separado.</p>
354
+ </Step>
355
+
356
+ <SearchRow
357
+ onSubmit={(event) => {
358
+ event.preventDefault();
359
+ buscarProductos(busquedaProd);
360
+ }}
361
+ >
362
+ <input
363
+ type="text"
364
+ value={busquedaProd}
365
+ placeholder="Busca por UPC, SKU o nombre"
366
+ onChange={(event) => setBusquedaProd(event.target.value)}
367
+ />
368
+ <button type="submit" disabled={cargandoProd}>
369
+ {cargandoProd ? "Buscando…" : "Buscar"}
370
+ </button>
371
+ </SearchRow>
372
+
373
+ {candidatos.length > 0 && (
374
+ <Candidates>
375
+ {candidatos.map((c) => (
376
+ <li key={c.articleId}>
377
+ <label>
378
+ <input
379
+ type="checkbox"
380
+ checked={elegidos.includes(c.articleId)}
381
+ onChange={() => alternar(c.articleId)}
382
+ />
383
+ <span className="datos">
384
+ <span className="nombre">{c.name}</span>
385
+ <span className="ids">
386
+ {c.upc ? `UPC ${c.upc}` : ""}
387
+ {c.upc && c.sku ? " · " : ""}
388
+ {c.sku ? `SKU ${c.sku}` : ""}
389
+ </span>
390
+ </span>
391
+ {c.currentGroup && c.currentGroup !== codigoFinal && (
392
+ <span className="mueve">hoy en «{c.currentGroup}»</span>
393
+ )}
394
+ </label>
395
+ </li>
396
+ ))}
397
+ </Candidates>
398
+ )}
399
+
400
+ {!cargandoProd && candidatos.length === 0 && (
401
+ <Message>
402
+ {busquedaProd.trim()
403
+ ? "Ningún producto coincide con esa búsqueda."
404
+ : "No hay otros productos que puedas agrupar aquí."}
405
+ </Message>
406
+ )}
407
+
408
+ {totalCandidatos > candidatos.length && (
409
+ <Message className="sutil">
410
+ Se muestran {candidatos.length} de {totalCandidatos}. Afina la
411
+ búsqueda para ver el resto.
412
+ </Message>
413
+ )}
414
+
415
+ {seMueven > 0 && (
416
+ <Message className="aviso">
417
+ {seMueven} de los productos elegidos ya está en otro grupo y se
418
+ moverá a «{codigoFinal}». Un producto solo puede estar en uno.
419
+ </Message>
420
+ )}
421
+ </>
422
+ )}
226
423
  </Body>
227
424
 
228
425
  <Footer>
@@ -240,7 +437,7 @@ export const VariantGroupModal = ({
240
437
  type="button"
241
438
  className="primary"
242
439
  onClick={guardar}
243
- disabled={saving || !codigoFinal || yaEstaba}
440
+ disabled={!puedeGuardar}
244
441
  >
245
442
  {saving ? "Guardando…" : "Aceptar"}
246
443
  </button>
@@ -159,9 +159,30 @@ export const NewGroup = styled.div`
159
159
  border-radius: 6px;
160
160
  background-color: rgba(${PINK_RGB}, 0.04);
161
161
 
162
+ .encabezado {
163
+ display: flex;
164
+ align-items: baseline;
165
+ justify-content: space-between;
166
+ gap: 12px;
167
+ margin-bottom: 6px;
168
+ }
169
+
170
+ /* Salir de aqui tiene que costar lo mismo que entrar: si abrir el creador es
171
+ un clic, cerrarlo tambien. Sin esto el usuario queda atrapado en el paso de
172
+ crear y la unica salida visible es cerrar el modal entero. */
173
+ .encabezado button {
174
+ border: none;
175
+ background: none;
176
+ padding: 0;
177
+ font-family: ${FontFamily.Raleway_600};
178
+ font-size: 11px;
179
+ color: ${GlobalColors.gray};
180
+ cursor: pointer;
181
+ text-decoration: underline;
182
+ }
183
+
162
184
  label {
163
185
  display: block;
164
- margin-bottom: 6px;
165
186
  font-family: ${FontFamily.Raleway_600};
166
187
  font-size: 12px;
167
188
  color: ${GlobalColors.deep_gray};
@@ -193,6 +214,117 @@ export const NewGroup = styled.div`
193
214
  }
194
215
  `;
195
216
 
217
+ export const Step = styled.div`
218
+ margin: 4px 0 10px;
219
+
220
+ h3 {
221
+ margin: 0;
222
+ font-family: ${FontFamily.Raleway_700};
223
+ font-size: 13px;
224
+ color: ${GlobalColors.deep_gray};
225
+ }
226
+
227
+ p {
228
+ margin: 2px 0 0;
229
+ font-size: 11px;
230
+ color: ${GlobalColors.gray};
231
+ }
232
+
233
+ /* El segundo paso aparece y desaparece segun haya grupo elegido; la linea de
234
+ arriba es lo que evita que se lea como continuacion del bloque anterior. */
235
+ &.segundo {
236
+ margin-top: 18px;
237
+ padding-top: 14px;
238
+ border-top: 1px solid ${GlobalColors.s2};
239
+ }
240
+ `;
241
+
242
+ /*
243
+ El boton que revela el campo de codigo nuevo.
244
+
245
+ Va deliberadamente discreto: es la salida menos frecuente —lo normal es
246
+ sumarse a un grupo que ya existe— y competir en peso con "Buscar" volveria a
247
+ empujar hacia crear, que es justo lo que se queria dejar de hacer.
248
+ */
249
+ export const GhostButton = styled.button`
250
+ display: block;
251
+ width: 100%;
252
+ height: 36px;
253
+ border: 1px dashed rgba(${PINK_RGB}, 0.5);
254
+ border-radius: 6px;
255
+ background-color: rgba(${PINK_RGB}, 0.04);
256
+ font-family: ${FontFamily.Raleway_600};
257
+ font-size: 12px;
258
+ color: ${PINK};
259
+ cursor: pointer;
260
+
261
+ &:hover {
262
+ background-color: rgba(${PINK_RGB}, 0.09);
263
+ }
264
+ `;
265
+
266
+ export const Candidates = styled.ul`
267
+ margin: 0 0 10px;
268
+ padding: 0;
269
+ list-style: none;
270
+ border: 1px solid ${GlobalColors.s2};
271
+ border-radius: 6px;
272
+ /* Acotado a proposito: la lista vive dentro de un modal que ya scrollea, y sin
273
+ tope empuja el pie fuera de la vista. */
274
+ max-height: 210px;
275
+ overflow-y: auto;
276
+
277
+ li + li {
278
+ border-top: 1px solid ${GlobalColors.s2};
279
+ }
280
+
281
+ label {
282
+ display: flex;
283
+ align-items: center;
284
+ gap: 10px;
285
+ padding: 8px 12px;
286
+ cursor: pointer;
287
+ font-size: 13px;
288
+ color: ${GlobalColors.deep_gray};
289
+ }
290
+
291
+ label:hover {
292
+ background-color: ${GlobalColors.s1};
293
+ }
294
+
295
+ input[type="checkbox"] {
296
+ accent-color: ${PINK};
297
+ flex-shrink: 0;
298
+ }
299
+
300
+ .datos {
301
+ display: flex;
302
+ flex-direction: column;
303
+ min-width: 0;
304
+ }
305
+
306
+ .nombre {
307
+ font-family: ${FontFamily.Raleway_600};
308
+ overflow: hidden;
309
+ text-overflow: ellipsis;
310
+ white-space: nowrap;
311
+ }
312
+
313
+ .ids {
314
+ font-size: 11px;
315
+ color: ${GlobalColors.gray};
316
+ }
317
+
318
+ .mueve {
319
+ margin-left: auto;
320
+ flex-shrink: 0;
321
+ font-size: 11px;
322
+ font-style: italic;
323
+ color: ${PINK};
324
+ white-space: nowrap;
325
+ }
326
+ `;
327
+
196
328
  export const Message = styled.p`
197
329
  margin: 0 0 12px;
198
330
  padding: 10px 12px;
@@ -206,6 +338,21 @@ export const Message = styled.p`
206
338
  background-color: rgba(${PINK_RGB}, 0.1);
207
339
  color: ${PINK};
208
340
  }
341
+
342
+ /* Informativo, no accionable: no debe competir con el contenido. */
343
+ &.sutil {
344
+ background: none;
345
+ padding: 2px 0 10px;
346
+ font-size: 11px;
347
+ color: ${GlobalColors.gray};
348
+ }
349
+
350
+ /* Agrupar es un UPDATE, asi que sumar un producto que ya tenia grupo lo MUEVE.
351
+ Se avisa antes de guardar, no despues. */
352
+ &.aviso {
353
+ background-color: rgba(${PINK_RGB}, 0.08);
354
+ color: ${GlobalColors.deep_gray};
355
+ }
209
356
  `;
210
357
 
211
358
  export const Footer = styled.div`
@@ -734,10 +734,19 @@ const ProviderProductEditionView = ({
734
734
  // Guardar un padre con hijos abre la decisión de propagar. Sin este
735
735
  // paso el cambio se queda solo en el padre y los hijos se van
736
736
  // separando de él sin que nadie lo note.
737
- const esPadre = state.product?.variantsCount > 0;
737
+ /*
738
+ El diálogo se abre si hay ALGÚN destino posible, no solo hijos: un
739
+ producto puede no tener hijos y aun así pertenecer a un grupo de
740
+ variantes, y ese cambio también se le puede copiar a sus compañeras.
741
+ */
742
+ const companeras = (variantGroup?.members ?? []).filter(
743
+ (m) => !m.isCurrent
744
+ );
745
+ const hayDestinos =
746
+ state.product?.variantsCount > 0 || companeras.length > 0;
738
747
  const cambios = cambiosDeFichaTecnica();
739
748
 
740
- if (esPadre && cambios.length > 0) {
749
+ if (hayDestinos && cambios.length > 0) {
741
750
  // Se espera a tener la familia antes de abrir: si no, el diálogo
742
751
  // aparece diciendo "0 hijos" hasta que llega la respuesta, que es
743
752
  // justo lo contrario de lo que va a preguntar.
@@ -1642,6 +1651,10 @@ const ProviderProductEditionView = ({
1642
1651
  <PropagateChangesModal
1643
1652
  changedAttributes={pendingPropagation.cambios}
1644
1653
  variants={(family?.variants ?? []).filter((v) => !v.isCurrent)}
1654
+ groupMembers={(variantGroup?.members ?? []).filter(
1655
+ (m) => !m.isCurrent
1656
+ )}
1657
+ groupCode={variantGroup?.group ?? null}
1645
1658
  saving={state.saving}
1646
1659
  onCancel={() => setPendingPropagation(null)}
1647
1660
  onConfirm={(propagation) => {
@@ -77,6 +77,7 @@ const allowedAccountsToViewChangeStatus = [
77
77
  "gmorales@contentoh.com",
78
78
  "rvega@contentoh.com",
79
79
  "amora@contentoh.com",
80
+ "maltamirano@contentoh.com",
80
81
  //PRODUCT DEV ACCOUNTS
81
82
  "oparra@contentoh.com",
82
83
  "zsegura@contentoh.com",
@@ -1318,7 +1319,16 @@ const RetailerProductEditionView = ({
1318
1319
 
1319
1320
  const hijos = (familia.variants ?? []).filter((v) => !v.isCurrent);
1320
1321
 
1321
- if (hijos.length > 0) {
1322
+ /*
1323
+ También cuentan las compañeras de grupo: un producto puede no tener
1324
+ hijos y aun así pertenecer a un grupo de variantes, y el cambio se les
1325
+ puede copiar igual. Si no se miraran, guardar no ofrecería nada.
1326
+ */
1327
+ const companeras = (variantGroup?.members ?? []).filter(
1328
+ (m) => !m.isCurrent
1329
+ );
1330
+
1331
+ if (hijos.length > 0 || companeras.length > 0) {
1322
1332
  setPendingPropagation({ cambios });
1323
1333
  return;
1324
1334
  }
@@ -2083,6 +2093,10 @@ const RetailerProductEditionView = ({
2083
2093
  <PropagateChangesModal
2084
2094
  changedAttributes={pendingPropagation.cambios}
2085
2095
  variants={(family?.variants ?? []).filter((v) => !v.isCurrent)}
2096
+ groupMembers={(variantGroup?.members ?? []).filter(
2097
+ (m) => !m.isCurrent
2098
+ )}
2099
+ groupCode={variantGroup?.group ?? null}
2086
2100
  saving={state.saving}
2087
2101
  onCancel={() => setPendingPropagation(null)}
2088
2102
  onConfirm={(propagation) => {