contentoh-components-library 21.6.29 → 21.6.31

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.
@@ -15,9 +15,12 @@ var _variables = require("../../../global-files/variables");
15
15
 
16
16
  var _templateObject;
17
17
 
18
- var Container = _styledComponents.default.div(_templateObject || (_templateObject = (0, _taggedTemplateLiteral2.default)(["\n display: flex;\n flex-direction: column;\n height: 100%;\n flex: 1;\n overflow: auto;\n\n .data-container {\n display: flex;\n flex: 0%;\n height: calc(100% - ", "px);\n .image-data-panel {\n width: 340px;\n\n & + * {\n margin-left: 10px;\n }\n }\n\n .product-information {\n width: 100%;\n height: 100%;\n display: flex;\n flex-direction: column;\n\n .services-information-container {\n height: 100%;\n overflow: auto;\n }\n\n .image-services {\n aside {\n display: grid;\n grid-template-columns: repeat(auto-fill, 179px);\n column-gap: 15px;\n row-gap: 15px;\n padding: 20px;\n }\n }\n\n .commentary-box {\n display: flex;\n justify-content: space-between;\n align-items: flex-end;\n padding: 10px;\n padding-left: 0;\n\n .action-buttons{\n display: flex;\n flex-direction: column;\n gap: 1rem;\n }\n\n .commentary {\n display: flex;\n align-items: flex-end;\n\n .input-container {\n width: 500px;\n\n .quill {\n height: 100px;\n }\n\n & + * {\n margin-left: 5px;\n }\n }\n\n .buttons-box {\n display: flex;\n width: 210px;\n flex-wrap: wrap;\n\n .general-transparent-button {\n & + * {\n margin-top: 5px;\n }\n }\n\n .general-transparent-button,\n .general-green-button,\n .general-button-disabled {\n width: fit-content;\n min-width: 201px;\n height: 40px;\n }\n }\n }\n }\n\n .feedback-box {\n display: flex;\n }\n\n .required-inputs-message {\n font-family: ", ";\n font-size: 13px;\n color: ", ";\n padding: 10px;\n display: flex;\n align-items: center;\n\n p + * {\n margin-top: 10px;\n }\n\n button {\n min-width: fit-content;\n }\n }\n }\n }\n #modal-message-box {\n width: 400px;\n height: 100px;\n }\n .container {\n width: 100%;\n height: 90%;\n .dropzone {\n height: 100%;\n width: 100%;\n position: relative;\n transition: all 0.5s ease;\n\n &.drag-active {\n background-color: rgba(", ", 0.05);\n border: 2px dashed ", ";\n border-radius: 8px;\n\n aside {\n opacity: 0.3;\n pointer-events: none;\n }\n }\n\n .drag-overlay {\n position: absolute;\n top: 0;\n left: 0;\n right: 0;\n bottom: 0;\n display: flex;\n align-items: center;\n justify-content: center;\n background-color: rgba(", ", 0.1);\n border-radius: 8px;\n pointer-events: none;\n z-index: 10;\n\n p {\n font-family: ", ";\n font-size: 24px;\n font-weight: 600;\n color: ", ";\n text-align: center;\n padding: 20px;\n background-color: ", ";\n border-radius: 8px;\n box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);\n }\n }\n }\n }\n"])), function (_ref) {
18
+ // Rosa ContentOh, el mismo del modal de ligar productos.
19
+ var PINK = "#E13AA8";
20
+
21
+ var Container = _styledComponents.default.div(_templateObject || (_templateObject = (0, _taggedTemplateLiteral2.default)(["\n display: flex;\n flex-direction: column;\n height: 100%;\n flex: 1;\n overflow: auto;\n\n .data-container {\n display: flex;\n flex: 0%;\n height: calc(100% - ", "px);\n .image-data-panel {\n width: 340px;\n\n & + * {\n margin-left: 10px;\n }\n }\n\n .product-information {\n width: 100%;\n height: 100%;\n display: flex;\n flex-direction: column;\n\n .services-information-container {\n height: 100%;\n overflow: auto;\n }\n\n .image-services {\n aside {\n display: grid;\n grid-template-columns: repeat(auto-fill, 179px);\n column-gap: 15px;\n row-gap: 15px;\n padding: 20px;\n }\n }\n\n .commentary-box {\n display: flex;\n justify-content: space-between;\n align-items: flex-end;\n padding: 10px;\n padding-left: 0;\n\n .action-buttons{\n display: flex;\n flex-direction: column;\n gap: 1rem;\n }\n\n .commentary {\n display: flex;\n align-items: flex-end;\n\n .input-container {\n width: 500px;\n\n .quill {\n height: 100px;\n }\n\n & + * {\n margin-left: 5px;\n }\n }\n\n .buttons-box {\n display: flex;\n width: 210px;\n flex-wrap: wrap;\n\n .general-transparent-button {\n & + * {\n margin-top: 5px;\n }\n }\n\n .general-transparent-button,\n .general-green-button,\n .general-button-disabled {\n width: fit-content;\n min-width: 201px;\n height: 40px;\n }\n }\n }\n }\n\n .feedback-box {\n display: flex;\n }\n\n .required-inputs-message {\n font-family: ", ";\n font-size: 13px;\n color: ", ";\n padding: 10px;\n display: flex;\n align-items: center;\n\n p + * {\n margin-top: 10px;\n }\n\n button {\n min-width: fit-content;\n }\n }\n }\n }\n #modal-message-box {\n width: 400px;\n height: 100px;\n }\n .container {\n width: 100%;\n height: 90%;\n .dropzone {\n height: 100%;\n width: 100%;\n position: relative;\n transition: all 0.5s ease;\n\n &.drag-active {\n background-color: rgba(", ", 0.05);\n border: 2px dashed ", ";\n border-radius: 8px;\n\n aside {\n opacity: 0.3;\n pointer-events: none;\n }\n }\n\n .drag-overlay {\n position: absolute;\n top: 0;\n left: 0;\n right: 0;\n bottom: 0;\n display: flex;\n align-items: center;\n justify-content: center;\n background-color: rgba(", ", 0.1);\n border-radius: 8px;\n pointer-events: none;\n z-index: 10;\n\n p {\n font-family: ", ";\n font-size: 24px;\n font-weight: 600;\n color: ", ";\n text-align: center;\n padding: 20px;\n background-color: ", ";\n border-radius: 8px;\n box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);\n }\n }\n }\n }\n\n .variants-actions {\n display: flex;\n align-items: center;\n gap: 10px;\n margin-bottom: 8px;\n\n .link-variants {\n border: 1px solid ", ";\n border-radius: 6px;\n background: none;\n padding: 6px 12px;\n font-family: ", ";\n font-size: 11px;\n color: ", ";\n cursor: pointer;\n }\n\n .variants-count {\n border: none;\n background: none;\n padding: 0;\n cursor: pointer;\n font-family: ", ";\n font-size: 11px;\n color: ", ";\n text-decoration: underline;\n }\n }\n\n .variants-family {\n display: flex;\n flex-direction: column;\n gap: 4px;\n margin-bottom: 10px;\n padding: 8px;\n border: 1px solid ", ";\n border-radius: 6px;\n\n .loading {\n margin: 0;\n font-family: ", ";\n font-size: 11px;\n color: ", ";\n }\n\n .family-row {\n display: flex;\n align-items: baseline;\n gap: 8px;\n padding: 6px 8px;\n border: none;\n border-radius: 4px;\n background: none;\n cursor: pointer;\n text-align: left;\n font-family: ", ";\n font-size: 12px;\n color: ", ";\n }\n\n .family-row:hover:not(:disabled) {\n background-color: ", ";\n }\n\n .family-row:disabled {\n cursor: default;\n opacity: 0.7;\n }\n\n .family-row .role {\n min-width: 96px;\n font-size: 10px;\n color: ", ";\n }\n\n .family-row.current .role {\n color: ", ";\n }\n\n .family-row .name {\n flex: 1;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n }\n\n .family-row .upc {\n font-size: 11px;\n color: ", ";\n }\n }\n"])), function (_ref) {
19
22
  var headerTop = _ref.headerTop;
20
23
  return headerTop;
21
- }, _variables.FontFamily.AvenirNext, _variables.GlobalColors.color_gray, _variables.GlobalColors.rgb_color_pink, _variables.GlobalColors.color_pink, _variables.GlobalColors.rgb_color_pink, _variables.FontFamily.AvenirNext, _variables.GlobalColors.color_pink, _variables.GlobalColors.white);
24
+ }, _variables.FontFamily.AvenirNext, _variables.GlobalColors.color_gray, _variables.GlobalColors.rgb_color_pink, _variables.GlobalColors.color_pink, _variables.GlobalColors.rgb_color_pink, _variables.FontFamily.AvenirNext, _variables.GlobalColors.color_pink, _variables.GlobalColors.white, PINK, _variables.FontFamily.Raleway_600, PINK, _variables.FontFamily.Raleway_500, _variables.GlobalColors.gray, _variables.GlobalColors.s2, _variables.FontFamily.Raleway_500, _variables.GlobalColors.gray, _variables.FontFamily.Raleway_500, _variables.GlobalColors.deep_gray, _variables.GlobalColors.s2, PINK, _variables.GlobalColors.gray, _variables.GlobalColors.gray);
22
25
 
23
26
  exports.Container = Container;
@@ -126,6 +126,8 @@ var sendMessage = /*#__PURE__*/function () {
126
126
  exports.sendMessage = sendMessage;
127
127
 
128
128
  var normalizeProduct = function normalizeProduct(product) {
129
+ var _ref5, _productData$parentAr, _ref6, _productData$parent, _ref7, _productData$variants;
130
+
129
131
  // Handle case where product might be wrapped in a product property
130
132
  var productData = product.product || product;
131
133
  var article = productData.article || {};
@@ -161,6 +163,12 @@ var normalizeProduct = function normalizeProduct(product) {
161
163
  percentages: productData.percentages || null,
162
164
  summary: productData.summary || null,
163
165
  version: productData.version || 0,
166
+ // Parentesco. read-articles-v2 ya lo devuelve; sin arrastrarlo hasta aquí la
167
+ // pantalla no puede saber si el producto pertenece a una familia.
168
+ // `?? null` en vez de `|| null`: variantsCount 0 es un dato válido.
169
+ parentArticleId: (_ref5 = (_productData$parentAr = productData.parentArticleId) !== null && _productData$parentAr !== void 0 ? _productData$parentAr : article.parentArticleId) !== null && _ref5 !== void 0 ? _ref5 : null,
170
+ parent: (_ref6 = (_productData$parent = productData.parent) !== null && _productData$parent !== void 0 ? _productData$parent : article.parent) !== null && _ref6 !== void 0 ? _ref6 : null,
171
+ variantsCount: (_ref7 = (_productData$variants = productData.variantsCount) !== null && _productData$variants !== void 0 ? _productData$variants : article.variantsCount) !== null && _ref7 !== void 0 ? _ref7 : 0,
164
172
  id_order: productData.orderId || productData.id_order || 0,
165
173
  status: productData.status || null,
166
174
  datasheet_status: productData.datasheet_status || null,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "contentoh-components-library",
3
- "version": "21.6.29",
3
+ "version": "21.6.31",
4
4
  "dependencies": {
5
5
  "@aws-amplify/auth": "^4.5.3",
6
6
  "@aws-amplify/datastore": "^3.11.0",
@@ -86,6 +86,8 @@ const ProviderProductEditionView = ({
86
86
  const [loadingVariant, setLoadingVariant] = useState(null);
87
87
  const [pendingPropagation, setPendingPropagation] = useState(null);
88
88
  const [preparandoPropagacion, setPreparandoPropagacion] = useState(false);
89
+ const [preparandoEdicionMultiple, setPreparandoEdicionMultiple] =
90
+ useState(false);
89
91
  const [showRejectModal, setShowRejectModal] = useState(false);
90
92
  const [headerTop, setHeaderTop] = useState(0);
91
93
  const [assig, setAssig] = useState({});
@@ -802,33 +804,63 @@ const ProviderProductEditionView = ({
802
804
  Abre la edición múltiple con las variantes de la familia ya cargadas.
803
805
 
804
806
  Esa pantalla lee `multipleEditionList` de sessionStorage —así la deja el
805
- listado al pulsar "editar varios"— y de cada producto solo usa id_article y
806
- version. Se reproduce esa forma en vez de inventar otra vía de entrada.
807
+ listado al pulsar "editar varios"— y necesita el PRODUCTO COMPLETO de cada
808
+ uno, no solo su id: pinta el UPC, el nombre, parte `categoryName` por "|",
809
+ cuenta `retailersAvailable` y decide columnas según `id_order`. Con un objeto
810
+ reducido revienta al entrar.
807
811
 
808
- Va SIN el producto padre a propósito, aunque estemos parados en su ficha: en
809
- la edición múltiple no existe el diálogo de propagación, así que incluirlo
812
+ Por eso se piden a /new-articles, que devuelve justo la forma del listado, y
813
+ se filtran por id y no por UPC: hay 12,024 UPC repetidos entre artículos
814
+ activos y el UPC no identifica un producto.
815
+
816
+ Va SIN el producto padre a propósito, aunque el botón viva en su ficha. En la
817
+ edición múltiple no existe el diálogo de propagación, así que incluirlo
810
818
  abriría un camino para cambiar el padre sin decidir si eso baja a los hijos
811
- —justo la decisión que la ficha obliga a tomar—. Para editar el padre está su
812
- propia pantalla.
819
+ —justo la decisión que su propia ficha obliga a tomar—.
813
820
  */
814
- const openMultipleEdition = () => {
815
- const variantes = (family?.variants ?? []).map((variant) => ({
816
- id_article: variant.articleId,
817
- version: variant.version ?? 1,
818
- }));
819
-
820
- if (!variantes.length) return;
821
+ const openMultipleEdition = async () => {
822
+ const ids = (family?.variants ?? []).map((variant) => variant.articleId);
823
+ if (!ids.length) return;
821
824
 
825
+ setPreparandoEdicionMultiple(true);
822
826
  try {
827
+ const res = await axios.get(`${process.env.REACT_APP_NEW_ARTICLES_ENDPOINT}`, {
828
+ params: { listType: "general", filterByArticleId: ids.join(",") },
829
+ headers: { Authorization: token },
830
+ });
831
+
832
+ const { data } = readPayload(res);
833
+ const productos = (data ?? []).filter((item) =>
834
+ ids.includes(Number(item.id_article))
835
+ );
836
+
837
+ if (!productos.length) {
838
+ console.error("openMultipleEdition: sin productos que abrir");
839
+ return;
840
+ }
841
+
823
842
  sessionStorage.setItem(
824
843
  "multipleEditionList",
825
844
  JSON.stringify(
826
- variantes.sort((a, b) => a.id_article - b.id_article)
845
+ productos.sort((a, b) => a.id_article - b.id_article)
827
846
  )
828
847
  );
829
848
  window.location.assign("/products/multipleEdition");
830
849
  } catch (error) {
850
+ // Se avisa y se queda donde está: navegar con la lista vieja abriría otros
851
+ // productos, y navegar sin lista deja la pantalla rota.
831
852
  console.error("openMultipleEdition: ", error);
853
+ dispatch({
854
+ type: "SET_MODAL",
855
+ payload: {
856
+ show: true,
857
+ title: "",
858
+ message: "No se pudieron cargar las variantes para editarlas juntas.",
859
+ image: errorIcon,
860
+ },
861
+ });
862
+ } finally {
863
+ setPreparandoEdicionMultiple(false);
832
864
  }
833
865
  };
834
866
 
@@ -1741,9 +1773,12 @@ const ProviderProductEditionView = ({
1741
1773
  <button
1742
1774
  type="button"
1743
1775
  className="editar-juntas"
1776
+ disabled={preparandoEdicionMultiple}
1744
1777
  onClick={openMultipleEdition}
1745
1778
  >
1746
- Editar las {family.variants.length} variantes juntas
1779
+ {preparandoEdicionMultiple
1780
+ ? "Abriendo…"
1781
+ : `Editar las ${family.variants.length} variantes juntas`}
1747
1782
  </button>
1748
1783
  )}
1749
1784
  </div>
@@ -137,13 +137,20 @@ export const ProviderProductEditionProvider = ({ children }) => {
137
137
  }
138
138
  };
139
139
 
140
- const saveDatasheets = async (token) => {
140
+ /*
141
+ `propagation` es opcional y solo lo manda la ficha de un producto padre, con
142
+ lo que el usuario eligió en el diálogo. Sin ese bloque el backend no propaga
143
+ nada: bajar un cambio a las variantes es una decisión, no un efecto
144
+ secundario de guardar.
145
+ */
146
+ const saveDatasheets = async (token, propagation) => {
141
147
  if (state.updated_datasheets_inputs.length === 0) return;
142
148
  dispatch({ type: "SET_SAVING", payload: true });
143
149
 
144
150
  const dataObject = {
145
151
  articleId: state.product?.id_article,
146
152
  articleData: state.updated_datasheets_inputs,
153
+ ...(propagation ? { propagation } : {}),
147
154
  };
148
155
 
149
156
  if (state.product?.id_order) {
@@ -174,9 +181,22 @@ export const ProviderProductEditionProvider = ({ children }) => {
174
181
  type: "SET_UPDATED_DATASHEETS_INPUTS",
175
182
  payload: [],
176
183
  });
184
+ // En una variante, todo atributo que el hijo toca deja de ser heredado:
185
+ // update-articles-data le pone is_overridden = 1 en la base. Sin
186
+ // reflejarlo aquí, el chip sigue diciendo "Heredado" hasta la siguiente
187
+ // carga de la ficha, y el usuario ve una marca que ya es falsa.
188
+ const esVariante =
189
+ state.services?.datasheets?.variant?.isVariant ??
190
+ !!state.product?.parentArticleId;
191
+
177
192
  dispatch({
178
193
  type: "UPDATE_DATASHEETS_INPUTS",
179
- payload: state.updated_datasheets_inputs,
194
+ payload: esVariante
195
+ ? state.updated_datasheets_inputs.map((item) => ({
196
+ ...item,
197
+ isOverridden: 1,
198
+ }))
199
+ : state.updated_datasheets_inputs,
180
200
  });
181
201
 
182
202
  // Contar cuántos campos requeridos que antes estaban vacíos ahora tienen valor
@@ -50,6 +50,9 @@ import { InputGroup } from "../../organisms/InputGroup";
50
50
  import { ProductImageModal } from "../../organisms/ProductImageModal";
51
51
  import { VersionSelector } from "../../organisms/VersionSelector";
52
52
  import { Modal } from "../../organisms/Modal";
53
+ import { LinkVariantsModal } from "../../organisms/LinkVariantsModal";
54
+ import { PropagateChangesModal } from "../../organisms/PropagateChangesModal";
55
+ import { readPayload } from "../../organisms/LinkVariantsModal/api";
53
56
 
54
57
  import successIcon from "../../../assets/images/genericModal/genericModalCheck.svg";
55
58
  import errorIcon from "../../../assets/images/genericModal/errorModal.svg";
@@ -178,6 +181,14 @@ const RetailerProductEditionView = ({
178
181
  const [statusArray, setStatusArray] = useState([]);
179
182
  const [rejectAll, setRejectAll] = useState(false);
180
183
 
184
+ // Variantes: mismo comportamiento que la ficha de la aplicación de clientes.
185
+ const [showLinkVariants, setShowLinkVariants] = useState(false);
186
+ const [family, setFamily] = useState(null);
187
+ const [showFamily, setShowFamily] = useState(false);
188
+ const [loadingVariant, setLoadingVariant] = useState(null);
189
+ const [pendingPropagation, setPendingPropagation] = useState(null);
190
+ const [preparandoPropagacion, setPreparandoPropagacion] = useState(false);
191
+
181
192
  const [auditServices, setAuditServices] = useState([]);
182
193
  const [auditDatasheets, setAuditDatasheets] = useState([]);
183
194
  const [auditDescriptions, setAuditDescriptions] = useState([]);
@@ -771,16 +782,170 @@ const RetailerProductEditionView = ({
771
782
  }
772
783
  };
773
784
 
785
+ /*
786
+ Trae la familia: las variantes del producto, o sus hermanas si el abierto es
787
+ una de ellas. Se pide al abrir la sección y no al montar la pantalla: la
788
+ mayoría de los productos no son familia.
789
+ */
790
+ const loadFamily = async () => {
791
+ if (family) return family;
792
+ try {
793
+ const res = await axios.post(
794
+ `${process.env.REACT_APP_VARIANTS_ENDPOINT}/family`,
795
+ { articleId: state.product?.id_article },
796
+ { headers: { Authorization: token } }
797
+ );
798
+ const data = readPayload(res);
799
+ setFamily(data);
800
+ return data;
801
+ } catch (error) {
802
+ console.error("loadFamily: ", error);
803
+ setFamily({ variants: [], parent: null, error: true });
804
+ return null;
805
+ }
806
+ };
807
+
808
+ /*
809
+ Abre otra variante. Esta pantalla lee el producto de sessionStorage, no de la
810
+ URL, así que hay que dejarlo ahí antes de navegar; si no, cambia la dirección
811
+ y sigue mostrando el producto anterior.
812
+
813
+ Se pide por ID y no por UPC: hay 12,024 UPC repetidos entre artículos activos
814
+ y 5,579 de esos casos caen dentro de la misma compañía.
815
+ */
816
+ const openVariant = async (articleId) => {
817
+ setLoadingVariant(articleId);
818
+ try {
819
+ const res = await axios.get(`${process.env.REACT_APP_NEW_ARTICLES_ENDPOINT}`, {
820
+ params: { listType: "general", filterByArticleId: articleId },
821
+ headers: { Authorization: token },
822
+ });
823
+
824
+ const { data } = readPayload(res);
825
+ const encontrado = (data ?? []).find(
826
+ (item) => Number(item.id_article) === Number(articleId)
827
+ );
828
+
829
+ if (encontrado) {
830
+ sessionStorage.setItem(
831
+ "productSelected",
832
+ JSON.stringify({
833
+ ...encontrado,
834
+ article: { id_article: encontrado.id_article },
835
+ })
836
+ );
837
+ }
838
+ } catch (error) {
839
+ // Se navega igual: dejar el clic sin efecto parecería que no responde.
840
+ console.error("openVariant: ", error);
841
+ } finally {
842
+ setLoadingVariant(null);
843
+ window.location.assign("/EditProducts");
844
+ }
845
+ };
846
+
847
+ /*
848
+ Refresca tras ligar, sin recargar: el producto sale de sessionStorage, así que
849
+ un refresh devolvería el mismo objeto y el contador seguiría igual.
850
+ */
851
+ const handleOnLinkedVariants = (result) => {
852
+ if (!result) return;
853
+
854
+ const updatedProduct =
855
+ result.mode === "children"
856
+ ? {
857
+ ...state.product,
858
+ variantsCount:
859
+ (state.product?.variantsCount ?? 0) + (result.linked ?? []).length,
860
+ }
861
+ : {
862
+ ...state.product,
863
+ parentArticleId: result.parentArticleId ?? null,
864
+ variantsCount: 0,
865
+ };
866
+
867
+ dispatch({ type: "SET_PRODUCT", payload: updatedProduct });
868
+ setFamily(null);
869
+
870
+ try {
871
+ sessionStorage.setItem("productSelected", JSON.stringify(updatedProduct));
872
+ } catch (error) {
873
+ console.error("linkVariants/sessionStorage: ", error);
874
+ }
875
+ };
876
+
877
+ /*
878
+ Acción "Volver a heredar" de los chips. Repinta en sitio con lo que devuelve
879
+ el endpoint: recargar la ficha se llevaría lo que haya sin guardar.
880
+ */
881
+ const handleOnRestoreInheritance = async (attributeId) => {
882
+ if (!attributeId || !state.product?.id_article) return;
883
+
884
+ try {
885
+ const res = await axios.post(
886
+ `${process.env.REACT_APP_VARIANTS_ENDPOINT}/restore`,
887
+ { articleId: state.product.id_article, attributes: [Number(attributeId)] },
888
+ { headers: { Authorization: token } }
889
+ );
890
+
891
+ const data = readPayload(res);
892
+
893
+ dispatch({
894
+ type: "UPDATE_DATASHEETS_INPUTS",
895
+ payload: (data.values || []).map((item) => ({
896
+ attributeId: item.id,
897
+ value: item.value,
898
+ isOverridden: 0,
899
+ })),
900
+ });
901
+ } catch (error) {
902
+ console.error("restoreInheritance: ", error);
903
+ }
904
+ };
905
+
906
+ /*
907
+ Los atributos recién tocados, con el nombre que ve el usuario:
908
+ `updated_datasheets_inputs` solo trae { attributeId, value }.
909
+ */
910
+ const cambiosDeFichaTecnica = () => {
911
+ const retailerId = state.active_retailer?.id_retailer;
912
+ const inputs = state.services?.datasheets?.inputs ?? {};
913
+
914
+ return (state.updated_datasheets_inputs ?? []).map((item) => {
915
+ const input = inputs[`${item.attributeId}_${retailerId}`];
916
+ return {
917
+ id: Number(item.attributeId),
918
+ name: input?.name ?? `Atributo ${item.attributeId}`,
919
+ value: item.value,
920
+ };
921
+ });
922
+ };
923
+
774
924
  const handleOnClickSave = async () => {
775
- if (state.saving) return;
925
+ if (state.saving || preparandoPropagacion) return;
776
926
 
777
927
  switch (state.active_tab) {
778
928
  case "Descripción":
779
929
  await saveDescriptions(token);
780
930
  break;
781
- case "Ficha técnica":
931
+ case "Ficha técnica": {
932
+ // Guardar un padre con variantes abre la decisión de propagar. Sin este
933
+ // paso el cambio se queda solo en el padre y las variantes se van
934
+ // separando de él sin que nadie lo note.
935
+ const esPadre = state.product?.variantsCount > 0;
936
+ const cambios = cambiosDeFichaTecnica();
937
+
938
+ if (esPadre && cambios.length > 0) {
939
+ setPreparandoPropagacion(true);
940
+ loadFamily()
941
+ .then(() => setPendingPropagation({ cambios }))
942
+ .finally(() => setPreparandoPropagacion(false));
943
+ return;
944
+ }
945
+
782
946
  await saveDatasheets(token);
783
947
  break;
948
+ }
784
949
  case "Imágenes":
785
950
  await updateImages(token);
786
951
  break;
@@ -1534,6 +1699,29 @@ const RetailerProductEditionView = ({
1534
1699
  state={state}
1535
1700
  >
1536
1701
  <Container headerTop={headerTop}>
1702
+ {pendingPropagation && (
1703
+ <PropagateChangesModal
1704
+ changedAttributes={pendingPropagation.cambios}
1705
+ variants={(family?.variants ?? []).filter((v) => !v.isCurrent)}
1706
+ saving={state.saving}
1707
+ onCancel={() => setPendingPropagation(null)}
1708
+ onConfirm={(propagation) => {
1709
+ setPendingPropagation(null);
1710
+ saveDatasheets(token, propagation ?? undefined);
1711
+ }}
1712
+ />
1713
+ )}
1714
+ {showLinkVariants && (
1715
+ <LinkVariantsModal
1716
+ articleId={state.product?.id_article}
1717
+ articleName={state.product?.name}
1718
+ articleUpc={state.product?.upc}
1719
+ activeRetailerId={state.active_retailer?.id_retailer}
1720
+ token={token}
1721
+ onClose={() => setShowLinkVariants(false)}
1722
+ onLinked={handleOnLinkedVariants}
1723
+ />
1724
+ )}
1537
1725
  {showRejectModal && (
1538
1726
  <Modal
1539
1727
  title={
@@ -1738,6 +1926,105 @@ const RetailerProductEditionView = ({
1738
1926
  </div>
1739
1927
 
1740
1928
  <div className="product-information">
1929
+ {/* Solo si el producto NO es ya variante de alguien: ligarlo otra vez
1930
+ devolvería VARIANT_ALREADY_LINKED sin excepción. */}
1931
+ {!state.product?.parentArticleId && (
1932
+ <div className="variants-actions">
1933
+ <button
1934
+ type="button"
1935
+ className="link-variants"
1936
+ onClick={() => setShowLinkVariants(true)}
1937
+ >
1938
+ Ligar productos
1939
+ </button>
1940
+ {state.product?.variantsCount > 0 && (
1941
+ <button
1942
+ type="button"
1943
+ className="variants-count"
1944
+ aria-expanded={showFamily}
1945
+ onClick={() => {
1946
+ setShowFamily((abierto) => !abierto);
1947
+ loadFamily();
1948
+ }}
1949
+ >
1950
+ {state.product.variantsCount} variante
1951
+ {state.product.variantsCount === 1 ? "" : "s"}
1952
+ <span aria-hidden="true">{showFamily ? " ▴" : " ▾"}</span>
1953
+ </button>
1954
+ )}
1955
+ </div>
1956
+ )}
1957
+
1958
+ {state.product?.parentArticleId && (
1959
+ <div className="variants-actions">
1960
+ <button
1961
+ type="button"
1962
+ className="variants-count"
1963
+ aria-expanded={showFamily}
1964
+ onClick={() => {
1965
+ setShowFamily((abierto) => !abierto);
1966
+ loadFamily();
1967
+ }}
1968
+ >
1969
+ Variante de otro producto
1970
+ <span aria-hidden="true">{showFamily ? " ▴" : " ▾"}</span>
1971
+ </button>
1972
+ </div>
1973
+ )}
1974
+
1975
+ {showFamily && (
1976
+ <div className="variants-family">
1977
+ {!family && <p className="loading">Cargando familia…</p>}
1978
+
1979
+ {family?.error && (
1980
+ <p className="loading">
1981
+ No se pudo cargar la familia de este producto.
1982
+ </p>
1983
+ )}
1984
+
1985
+ {family?.parent && (
1986
+ <button
1987
+ type="button"
1988
+ className="family-row parent"
1989
+ disabled={loadingVariant !== null}
1990
+ onClick={() => openVariant(family.parent.articleId)}
1991
+ >
1992
+ <span className="role">
1993
+ {loadingVariant === family.parent.articleId
1994
+ ? "Abriendo…"
1995
+ : "Producto padre"}
1996
+ </span>
1997
+ <span className="name">{family.parent.name}</span>
1998
+ <span className="upc">{family.parent.upc}</span>
1999
+ </button>
2000
+ )}
2001
+
2002
+ {family?.variants?.map((variant) => (
2003
+ <button
2004
+ key={variant.articleId}
2005
+ type="button"
2006
+ className={`family-row${variant.isCurrent ? " current" : ""}`}
2007
+ disabled={variant.isCurrent || loadingVariant !== null}
2008
+ onClick={() => openVariant(variant.articleId)}
2009
+ >
2010
+ <span className="role">
2011
+ {loadingVariant === variant.articleId
2012
+ ? "Abriendo…"
2013
+ : variant.isCurrent
2014
+ ? "Estás aquí"
2015
+ : "Variante"}
2016
+ </span>
2017
+ <span className="name">{variant.name}</span>
2018
+ <span className="upc">{variant.upc}</span>
2019
+ </button>
2020
+ ))}
2021
+
2022
+ {family && !family.error && !family.variants?.length && (
2023
+ <p className="loading">Este producto no tiene variantes.</p>
2024
+ )}
2025
+ </div>
2026
+ )}
2027
+
1741
2028
  <FullProductNameHeader
1742
2029
  // states
1743
2030
  headerData={state.product}
@@ -1845,6 +2132,8 @@ const RetailerProductEditionView = ({
1845
2132
  activeSection={state.active_tab}
1846
2133
  inputGroup={dataGroup}
1847
2134
  dataInputs={state.datasheets_inputs[1]}
2135
+ variant={state.services?.datasheets?.variant}
2136
+ onRestoreInheritance={handleOnRestoreInheritance}
1848
2137
  auditInputs={auditDatasheets[1]}
1849
2138
  updatedDatasheets={
1850
2139
  state.updated_datasheets_inputs
@@ -1,6 +1,9 @@
1
1
  import styled from "styled-components";
2
2
  import { FontFamily, GlobalColors } from "../../../global-files/variables";
3
3
 
4
+ // Rosa ContentOh, el mismo del modal de ligar productos.
5
+ const PINK = "#E13AA8";
6
+
4
7
  export const Container = styled.div`
5
8
  display: flex;
6
9
  flex-direction: column;
@@ -166,4 +169,97 @@ export const Container = styled.div`
166
169
  }
167
170
  }
168
171
  }
172
+
173
+ .variants-actions {
174
+ display: flex;
175
+ align-items: center;
176
+ gap: 10px;
177
+ margin-bottom: 8px;
178
+
179
+ .link-variants {
180
+ border: 1px solid ${PINK};
181
+ border-radius: 6px;
182
+ background: none;
183
+ padding: 6px 12px;
184
+ font-family: ${FontFamily.Raleway_600};
185
+ font-size: 11px;
186
+ color: ${PINK};
187
+ cursor: pointer;
188
+ }
189
+
190
+ .variants-count {
191
+ border: none;
192
+ background: none;
193
+ padding: 0;
194
+ cursor: pointer;
195
+ font-family: ${FontFamily.Raleway_500};
196
+ font-size: 11px;
197
+ color: ${GlobalColors.gray};
198
+ text-decoration: underline;
199
+ }
200
+ }
201
+
202
+ .variants-family {
203
+ display: flex;
204
+ flex-direction: column;
205
+ gap: 4px;
206
+ margin-bottom: 10px;
207
+ padding: 8px;
208
+ border: 1px solid ${GlobalColors.s2};
209
+ border-radius: 6px;
210
+
211
+ .loading {
212
+ margin: 0;
213
+ font-family: ${FontFamily.Raleway_500};
214
+ font-size: 11px;
215
+ color: ${GlobalColors.gray};
216
+ }
217
+
218
+ .family-row {
219
+ display: flex;
220
+ align-items: baseline;
221
+ gap: 8px;
222
+ padding: 6px 8px;
223
+ border: none;
224
+ border-radius: 4px;
225
+ background: none;
226
+ cursor: pointer;
227
+ text-align: left;
228
+ font-family: ${FontFamily.Raleway_500};
229
+ font-size: 12px;
230
+ color: ${GlobalColors.deep_gray};
231
+ }
232
+
233
+ .family-row:hover:not(:disabled) {
234
+ background-color: ${GlobalColors.s2};
235
+ }
236
+
237
+ .family-row:disabled {
238
+ cursor: default;
239
+ opacity: 0.7;
240
+ }
241
+
242
+ .family-row .role {
243
+ min-width: 96px;
244
+ font-size: 10px;
245
+ color: ${PINK};
246
+ }
247
+
248
+ .family-row.current .role {
249
+ color: ${GlobalColors.gray};
250
+ }
251
+
252
+ .family-row .name {
253
+ flex: 1;
254
+ overflow: hidden;
255
+ text-overflow: ellipsis;
256
+ white-space: nowrap;
257
+ }
258
+
259
+ .family-row .upc {
260
+ font-size: 11px;
261
+ color: ${GlobalColors.gray};
262
+ }
263
+ }
169
264
  `;
265
+
@@ -114,6 +114,14 @@ export const normalizeProduct = (product) => {
114
114
  percentages: productData.percentages || null,
115
115
  summary: productData.summary || null,
116
116
  version: productData.version || 0,
117
+
118
+ // Parentesco. read-articles-v2 ya lo devuelve; sin arrastrarlo hasta aquí la
119
+ // pantalla no puede saber si el producto pertenece a una familia.
120
+ // `?? null` en vez de `|| null`: variantsCount 0 es un dato válido.
121
+ parentArticleId:
122
+ productData.parentArticleId ?? article.parentArticleId ?? null,
123
+ parent: productData.parent ?? article.parent ?? null,
124
+ variantsCount: productData.variantsCount ?? article.variantsCount ?? 0,
117
125
  id_order: productData.orderId || productData.id_order || 0,
118
126
  status: productData.status || null,
119
127
  datasheet_status: productData.datasheet_status || null,