contentoh-components-library 21.6.59 → 21.6.61

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 (78) hide show
  1. package/dist/components/organisms/ServicesNavigation/ServiceToolbar.js +210 -0
  2. package/dist/components/organisms/ServicesNavigation/ServicesNavigation.stories.js +138 -0
  3. package/dist/components/organisms/ServicesNavigation/ServicesRail.js +705 -0
  4. package/dist/components/organisms/ServicesNavigation/catalog.js +505 -0
  5. package/dist/components/organisms/ServicesNavigation/index.js +337 -0
  6. package/dist/components/organisms/ServicesNavigation/model.js +167 -0
  7. package/dist/components/organisms/ServicesNavigation/ui.js +371 -0
  8. package/dist/components/organisms/ServicesNavigation/views.js +1237 -0
  9. package/dist/components/organisms/ServicesWorkspace/AssigneePicker.js +459 -0
  10. package/dist/components/organisms/ServicesWorkspace/ServicesWorkspace.stories.js +119 -0
  11. package/dist/components/organisms/ServicesWorkspace/demo.js +137 -0
  12. package/dist/components/organisms/ServicesWorkspace/index.js +813 -0
  13. package/dist/components/organisms/ServicesWorkspace/servicesModel.js +351 -0
  14. package/dist/components/organisms/ServicesWorkspace/tooltipStyles.js +37 -0
  15. package/dist/components/pages/CreatorsProductEdition/mocks/mockApi.js +40 -0
  16. package/dist/components/pages/CreatorsProductEdition/mocks/productServices.fixtures.js +387 -0
  17. package/dist/components/pages/CreatorsProductEdition/sections/StatusChip.js +13 -4
  18. package/dist/components/pages/ProductEdition/ProductEdition.stories.js +148 -0
  19. package/dist/components/pages/ProductEdition/ProductEditionView.js +1355 -0
  20. package/dist/components/pages/ProductEdition/ServiceHeader.js +142 -0
  21. package/dist/components/pages/ProductEdition/api.js +256 -0
  22. package/dist/components/pages/ProductEdition/index.js +37 -0
  23. package/dist/components/pages/ProductEdition/permissions.js +200 -0
  24. package/dist/components/pages/ProductEdition/permissions.test.js +320 -0
  25. package/dist/components/pages/ProductEdition/sections/CatalogServicePanel.js +237 -0
  26. package/dist/components/pages/ProductEdition/sections/EditionTopBar.js +174 -0
  27. package/dist/components/pages/ProductEdition/sections/ImagePanel.js +440 -0
  28. package/dist/components/pages/ProductEdition/sections/ServiceTeam.js +275 -0
  29. package/dist/components/pages/ProductEdition/sections/ServiceTools.js +186 -0
  30. package/dist/components/pages/ProductEdition/services.js +233 -0
  31. package/dist/components/pages/ProductEdition/services.test.js +195 -0
  32. package/dist/components/pages/RetailerProductEdition/RetailerProductEdition.stories.js +15 -2
  33. package/dist/components/pages/RetailerProductEdition/context/provider-product-edition.context.js +2 -2
  34. package/dist/components/pages/RetailerProductEdition/index.js +57 -40
  35. package/dist/components/pages/RetailerProductEdition/utils.js +6 -2
  36. package/dist/index.js +13 -0
  37. package/package.json +1 -1
  38. package/src/components/organisms/ServicesNavigation/ServiceToolbar.jsx +143 -0
  39. package/src/components/organisms/ServicesNavigation/ServicesNavigation.stories.js +101 -0
  40. package/src/components/organisms/ServicesNavigation/ServicesRail.jsx +591 -0
  41. package/src/components/organisms/ServicesNavigation/catalog.js +180 -0
  42. package/src/components/organisms/ServicesNavigation/index.jsx +232 -0
  43. package/src/components/organisms/ServicesNavigation/model.js +85 -0
  44. package/src/components/organisms/ServicesNavigation/ui.jsx +229 -0
  45. package/src/components/organisms/ServicesNavigation/views.jsx +705 -0
  46. package/src/components/organisms/ServicesWorkspace/AssigneePicker.js +348 -0
  47. package/src/components/organisms/ServicesWorkspace/ServicesWorkspace.stories.js +61 -0
  48. package/src/components/organisms/ServicesWorkspace/demo.js +58 -0
  49. package/src/components/organisms/ServicesWorkspace/index.js +556 -0
  50. package/src/components/organisms/ServicesWorkspace/servicesModel.js +203 -0
  51. package/src/components/organisms/ServicesWorkspace/tooltipStyles.js +27 -0
  52. package/src/components/pages/CreatorsProductEdition/mocks/mockApi.js +17 -0
  53. package/src/components/pages/CreatorsProductEdition/mocks/productServices.fixtures.js +175 -0
  54. package/src/components/pages/CreatorsProductEdition/sections/StatusChip.jsx +8 -4
  55. package/src/components/pages/ProductEdition/ProductEdition.stories.js +106 -0
  56. package/src/components/pages/ProductEdition/ProductEditionView.jsx +1142 -0
  57. package/src/components/pages/ProductEdition/ServiceHeader.jsx +104 -0
  58. package/src/components/pages/ProductEdition/api.js +84 -0
  59. package/src/components/pages/ProductEdition/index.js +21 -0
  60. package/src/components/pages/ProductEdition/permissions.js +128 -0
  61. package/src/components/pages/ProductEdition/permissions.test.js +249 -0
  62. package/src/components/pages/ProductEdition/sections/CatalogServicePanel.jsx +123 -0
  63. package/src/components/pages/ProductEdition/sections/EditionTopBar.jsx +134 -0
  64. package/src/components/pages/ProductEdition/sections/ImagePanel.jsx +342 -0
  65. package/src/components/pages/ProductEdition/sections/ServiceTeam.jsx +184 -0
  66. package/src/components/pages/ProductEdition/sections/ServiceTools.jsx +134 -0
  67. package/src/components/pages/ProductEdition/services.js +187 -0
  68. package/src/components/pages/ProductEdition/services.test.js +88 -0
  69. package/src/components/pages/RetailerProductEdition/RetailerProductEdition.stories.js +12 -0
  70. package/src/components/pages/RetailerProductEdition/context/provider-product-edition.context.jsx +6 -2
  71. package/src/components/pages/RetailerProductEdition/index.js +28 -3
  72. package/src/components/pages/RetailerProductEdition/utils.js +5 -0
  73. package/src/index.js +1 -0
  74. package/.claude/settings.local.json +0 -7
  75. package/dist/ai/prompts/attribute.prompt.js +0 -28
  76. package/dist/ai/prompts/description.prompt.js +0 -33
  77. package/dist/ai/schemas/attribute.schema.js +0 -35
  78. package/dist/ai/schemas/description.schema.js +0 -27
@@ -0,0 +1,184 @@
1
+ import { useEffect, useState } from "react";
2
+ import {
3
+ Avatar,
4
+ Box,
5
+ ButtonBase,
6
+ List,
7
+ ListItemAvatar,
8
+ ListItemButton,
9
+ ListItemText,
10
+ Popover,
11
+ Stack,
12
+ Tooltip,
13
+ Typography,
14
+ } from "@mui/material";
15
+
16
+ import { getProfilePicture } from "../../../../global-files/data";
17
+ import { COLORS, FONTS } from "../../CreatorsProductEdition/sections/tokens";
18
+
19
+ /*
20
+ Equipo del servicio abierto: especialista, coordinador y auditor, con foto,
21
+ rol y nombre («Tú» para quien está viendo).
22
+
23
+ Reemplaza al grupo de avatares «Asig.» de la barra de pestañas, que no decía
24
+ quién era quién. Quien puede asignar (permissions.canAssign) hace clic en el
25
+ especialista o en el coordinador y elige de la lista, igual que antes.
26
+
27
+ `roles` (opcional) limita los roles que se muestran: un servicio del catálogo
28
+ de flujo simple solo tiene especialista. Por omisión, los tres.
29
+ */
30
+
31
+ const ROLES = [
32
+ { role: "especialist", label: "Especialista", assignable: true },
33
+ { role: "facilitator", label: "Coordinador", assignable: true },
34
+ { role: "auditor", label: "Auditor", assignable: false },
35
+ ];
36
+
37
+ const nameOf = (person) =>
38
+ person ? [person.name, person.last_name].filter(Boolean).join(" ").trim() : "";
39
+
40
+ const initialsOf = (person) =>
41
+ nameOf(person)
42
+ .split(/\s+/)
43
+ .filter(Boolean)
44
+ .slice(0, 2)
45
+ .map((word) => word[0])
46
+ .join("")
47
+ .toUpperCase();
48
+
49
+ const PersonAvatar = ({ person, isMe, size = 26 }) => (
50
+ <Avatar
51
+ src={person?.id_user ? getProfilePicture(person.id_user, 48, 48) : undefined}
52
+ alt={nameOf(person)}
53
+ sx={{
54
+ width: size,
55
+ height: size,
56
+ fontSize: size * 0.4,
57
+ fontFamily: FONTS.Lato,
58
+ fontWeight: 700,
59
+ bgcolor: person ? COLORS.s2 : COLORS.white,
60
+ color: person ? COLORS.s5 : COLORS.s3,
61
+ border: `2px solid ${isMe ? COLORS.magenta_s2 : COLORS.white}`,
62
+ outline: person ? "none" : `1px dashed ${COLORS.s3}`,
63
+ outlineOffset: -3,
64
+ }}
65
+ >
66
+ {person ? initialsOf(person) : "?"}
67
+ </Avatar>
68
+ );
69
+
70
+ export const ServiceTeam = ({
71
+ assignees = {},
72
+ userId,
73
+ canAssign = false,
74
+ assig,
75
+ setAssignation,
76
+ resetKey,
77
+ roles,
78
+ }) => {
79
+ const [anchor, setAnchor] = useState(null);
80
+ const [role, setRole] = useState(null);
81
+ useEffect(() => setAnchor(null), [resetKey]);
82
+
83
+ const options = assig?.collaborators?.[role] ?? [];
84
+
85
+ return (
86
+ <>
87
+ <Stack direction="row" alignItems="center" spacing={1.5} sx={{ flexWrap: "wrap", rowGap: 0.75, minWidth: 0 }}>
88
+ {ROLES.filter((item) => !roles || roles.includes(item.role)).map((item) => {
89
+ const person = assignees[item.role];
90
+ const isMe = Boolean(person) && person.id_user === userId;
91
+ const clickable = canAssign && item.assignable;
92
+ const content = (
93
+ <Stack direction="row" alignItems="center" spacing={0.75}>
94
+ <PersonAvatar person={person} isMe={isMe} />
95
+ <Box sx={{ lineHeight: 1.1, textAlign: "left", minWidth: 0 }}>
96
+ <Typography
97
+ sx={{
98
+ fontFamily: FONTS.Lato,
99
+ fontSize: 10,
100
+ color: COLORS.s4,
101
+ textTransform: "uppercase",
102
+ letterSpacing: "0.05em",
103
+ }}
104
+ >
105
+ {item.label}
106
+ </Typography>
107
+ <Typography
108
+ noWrap
109
+ sx={{
110
+ maxWidth: 120,
111
+ fontFamily: FONTS.Lato,
112
+ fontSize: 12,
113
+ fontWeight: isMe ? 700 : 400,
114
+ color: person ? COLORS.s5 : COLORS.s3,
115
+ }}
116
+ >
117
+ {person ? (isMe ? "Tú" : nameOf(person)) : "Sin asignar"}
118
+ </Typography>
119
+ </Box>
120
+ </Stack>
121
+ );
122
+ const title = person ? `${item.label}: ${nameOf(person)}` : `${item.label}: sin asignar`;
123
+ return clickable ? (
124
+ <Tooltip key={item.role} title={`${title} · Clic para asignar`} arrow>
125
+ <ButtonBase
126
+ onClick={(event) => {
127
+ setRole(item.role);
128
+ setAnchor(event.currentTarget);
129
+ }}
130
+ sx={{ borderRadius: "8px", px: 0.5, py: 0.25, "&:hover": { bgcolor: COLORS.s2 } }}
131
+ >
132
+ {content}
133
+ </ButtonBase>
134
+ </Tooltip>
135
+ ) : (
136
+ <Tooltip key={item.role} title={title} arrow>
137
+ <Box sx={{ px: 0.5, py: 0.25 }}>{content}</Box>
138
+ </Tooltip>
139
+ );
140
+ })}
141
+ </Stack>
142
+
143
+ <Popover
144
+ open={Boolean(anchor)}
145
+ anchorEl={anchor}
146
+ onClose={() => setAnchor(null)}
147
+ anchorOrigin={{ vertical: "bottom", horizontal: "left" }}
148
+ transformOrigin={{ vertical: "top", horizontal: "left" }}
149
+ PaperProps={{ sx: { width: 260, maxWidth: "calc(100vw - 24px)", mt: 1, p: 1.5, borderRadius: "10px" } }}
150
+ >
151
+ <Typography sx={{ fontFamily: FONTS.Lato, fontSize: 12, fontWeight: 700, color: COLORS.s4, mb: 1 }}>
152
+ Asignar {ROLES.find((item) => item.role === role)?.label.toLowerCase()}
153
+ </Typography>
154
+ {options.length ? (
155
+ <List dense disablePadding sx={{ maxHeight: 280, overflowY: "auto" }}>
156
+ {options.map((person, index) => (
157
+ <ListItemButton
158
+ key={`${index}-${person?.id_user}`}
159
+ selected={person?.id_user === assignees[role]?.id_user}
160
+ onClick={() => {
161
+ setAssignation && setAssignation(role, person?.id_user);
162
+ setAnchor(null);
163
+ }}
164
+ sx={{ borderRadius: "6px" }}
165
+ >
166
+ <ListItemAvatar sx={{ minWidth: 38 }}>
167
+ <PersonAvatar person={person} />
168
+ </ListItemAvatar>
169
+ <ListItemText
170
+ primary={nameOf(person)}
171
+ primaryTypographyProps={{ sx: { fontFamily: FONTS.Lato, fontSize: 14, color: COLORS.s5 } }}
172
+ />
173
+ </ListItemButton>
174
+ ))}
175
+ </List>
176
+ ) : (
177
+ <Typography sx={{ fontFamily: FONTS.Lato, fontSize: 13, color: COLORS.s4, py: 1 }}>
178
+ Sin personas para asignar
179
+ </Typography>
180
+ )}
181
+ </Popover>
182
+ </>
183
+ );
184
+ };
@@ -0,0 +1,134 @@
1
+ import { Button, IconButton, Stack, Tooltip } from "@mui/material";
2
+
3
+ import { useAiProductEdition } from "../../../../contexts/AiProductEdition";
4
+
5
+ import saveIcon from "../../../../assets/images/generalButton/saveIcon.svg";
6
+ import saveIconHover from "../../../../assets/images/generalButton/saveIconHover.svg";
7
+ import deleteIcon from "../../../../assets/images/generalButton/deleteIcon.svg";
8
+ import deleteIconHover from "../../../../assets/images/generalButton/deleteIconHover.svg";
9
+ import downloadIcon from "../../../../assets/images/generalButton/downloadIcon.svg";
10
+ import rowsLayoutIcon from "../../../../assets/images/generalButton/rowLayout.svg";
11
+ import gridLayoutIcon from "../../../../assets/images/generalButton/gridLayout.svg";
12
+
13
+ import { COLORS, FONTS } from "../../CreatorsProductEdition/sections/tokens";
14
+
15
+ /*
16
+ Herramientas del servicio abierto: versión, guardar y, en imágenes, borrar,
17
+ descargar y cambiar de vista.
18
+
19
+ Sale de `ServiceActions` de CreatorsProductEdition sin tres cosas que ahora
20
+ viven en otro lado o ya no se usan:
21
+ - el botón de ayuda (las diapositivas de «Valida tu producto…»): el equipo
22
+ ya no lo usa;
23
+ - el estatus: lo muestra el encabezado del servicio junto a su nombre;
24
+ - las asignaciones: las muestra ServiceTeam con nombre y rol.
25
+ */
26
+
27
+ /** Botón redondo con el ícono de siempre y su versión «hover». */
28
+ const RoundIcon = ({ label, icon, hoverIcon, onClick, bordered = COLORS.s3, hoverBg = COLORS.magenta_s2, size = 15 }) => (
29
+ <Tooltip title={label} arrow>
30
+ <IconButton
31
+ aria-label={label}
32
+ onClick={onClick}
33
+ sx={{
34
+ width: 28,
35
+ height: 28,
36
+ p: 0,
37
+ border: bordered ? `1px solid ${bordered}` : "none",
38
+ backgroundImage: `url(${icon})`,
39
+ backgroundRepeat: "no-repeat",
40
+ backgroundPosition: "center",
41
+ backgroundSize: size,
42
+ "&:hover": {
43
+ backgroundColor: hoverBg,
44
+ borderColor: hoverBg,
45
+ ...(hoverIcon ? { backgroundImage: `url(${hoverIcon})` } : {}),
46
+ },
47
+ }}
48
+ />
49
+ </Tooltip>
50
+ );
51
+
52
+ export const ServiceTools = ({
53
+ imagesSection = false,
54
+ imageLayout,
55
+ setImageLayout,
56
+ downloadImages,
57
+ askToDeleteImages,
58
+ onClickSave,
59
+ showSaveButton,
60
+ version,
61
+ setShowVersionSelector,
62
+ }) => {
63
+ const { inputsUsingAi } = useAiProductEdition();
64
+
65
+ const save = () => {
66
+ if (Object.values(inputsUsingAi ?? {}).some((input) => !!input?.using)) {
67
+ console.warn("Para guardar se debe de dejar de usar la generación con IA");
68
+ return;
69
+ }
70
+ onClickSave && onClickSave();
71
+ };
72
+
73
+ return (
74
+ <Stack direction="row" alignItems="center" gap="8px" sx={{ flexShrink: 0 }}>
75
+ {version && (
76
+ <Tooltip title="Cambiar de versión" arrow>
77
+ <Button
78
+ onClick={(event) => {
79
+ event.stopPropagation();
80
+ setShowVersionSelector(true);
81
+ }}
82
+ sx={{
83
+ minWidth: 28,
84
+ width: 28,
85
+ height: 28,
86
+ p: 0,
87
+ borderRadius: "50%",
88
+ border: `1px solid ${COLORS.color_purple}`,
89
+ color: COLORS.color_purple,
90
+ fontFamily: FONTS.AvenirNext,
91
+ fontSize: 11,
92
+ lineHeight: "28px",
93
+ }}
94
+ >
95
+ V{version}
96
+ </Button>
97
+ </Tooltip>
98
+ )}
99
+ {showSaveButton && (
100
+ <RoundIcon
101
+ label="Guardar"
102
+ icon={saveIcon}
103
+ hoverIcon={saveIconHover}
104
+ bordered={COLORS.color_gray}
105
+ hoverBg={COLORS.color_pink}
106
+ size={undefined}
107
+ onClick={save}
108
+ />
109
+ )}
110
+ {imagesSection && (
111
+ <>
112
+ {showSaveButton && (
113
+ <RoundIcon
114
+ label="Borrar seleccionadas"
115
+ icon={deleteIcon}
116
+ hoverIcon={deleteIconHover}
117
+ onClick={() => askToDeleteImages && askToDeleteImages()}
118
+ />
119
+ )}
120
+ <RoundIcon label="Descargar" icon={downloadIcon} onClick={() => downloadImages && downloadImages()} />
121
+ <Tooltip title={imageLayout ? "Ver como lista" : "Ver como cuadrícula"} arrow>
122
+ <IconButton
123
+ aria-label={imageLayout ? "Ver como lista" : "Ver como cuadrícula"}
124
+ onClick={() => setImageLayout && setImageLayout(!imageLayout)}
125
+ sx={{ width: 28, height: 28, p: 0 }}
126
+ >
127
+ <img src={imageLayout ? rowsLayoutIcon : gridLayoutIcon} alt="" width={24} height={24} />
128
+ </IconButton>
129
+ </Tooltip>
130
+ </>
131
+ )}
132
+ </Stack>
133
+ );
134
+ };
@@ -0,0 +1,187 @@
1
+ import { SERVICE_CATALOG } from "../../organisms/ServicesNavigation/catalog";
2
+ import {
3
+ CLASSIC_SERVICES,
4
+ isAssignedToService,
5
+ isAuditorAssigned,
6
+ isPendingFor,
7
+ rolesInService,
8
+ tabOf,
9
+ } from "./permissions";
10
+
11
+ /*
12
+ Servicios del producto para la lista lateral de la edición.
13
+
14
+ Hoy los servicios con editor son los tres de siempre: salen de los estatus
15
+ del producto (`<servicio>_status`) y el de la cadena activa sale de
16
+ `services_data`. Un servicio con estatus vacío o «NS» no está contratado y
17
+ no aparece.
18
+
19
+ Esta lista es el respaldo de cuando GET /product-services no responde
20
+ (buildServicesFromApi, abajo): solo los servicios de siempre. Los servicios
21
+ del producto nunca salen de datos de muestra; la prop `demoServices` es solo
22
+ para las historias de Storybook.
23
+ */
24
+
25
+ const NOT_REQUESTED = [undefined, null, "", "NS", "NULL", "-"];
26
+
27
+ const catalogEntry = (serviceName) =>
28
+ SERVICE_CATALOG.find((item) => item.service_name === serviceName) ?? {
29
+ service_name: serviceName,
30
+ group: "review",
31
+ view: "files",
32
+ icon: null,
33
+ };
34
+
35
+ /** Persona de las listas de asignables (`collaborator_assignations`). */
36
+ const findUser = (collaborators, id) => {
37
+ if (!id) return null;
38
+ const all = Object.values(collaborators ?? {}).flat();
39
+ const user = all.find((item) => item?.id_user === id);
40
+ return user ?? { id_user: id, name: "Asignado", last_name: "" };
41
+ };
42
+
43
+ // `services_data` y `collaborator_assignations` llegan en null mientras carga.
44
+ const retailerStatus = (servicesData, serviceName, retailerId, fallback) =>
45
+ (servicesData ?? []).find((item) => item.service === serviceName && item.id_retailer === retailerId)?.status ??
46
+ fallback;
47
+
48
+ /**
49
+ * @returns {Array<{service_name, display_name, group, view, icon, status,
50
+ * tab, assignees, mine, pending, is_demo}>}
51
+ */
52
+ export const buildEditionServices = ({
53
+ product,
54
+ user,
55
+ servicesData,
56
+ activeRetailerId,
57
+ collaboratorAssignations,
58
+ // Auditores (rol 6): `collaborator_assignations` solo trae especialistas y
59
+ // coordinadores, y sin esta lista el auditor no tendría nombre.
60
+ auditors,
61
+ demoServices,
62
+ }) => {
63
+ if (!product) return [];
64
+
65
+ const classic = CLASSIC_SERVICES.filter(
66
+ (item) => !NOT_REQUESTED.includes(product[`${item.service_name}_status`])
67
+ ).map((item) => {
68
+ const definition = catalogEntry(item.service_name);
69
+ const collaborators = collaboratorAssignations?.[item.tab]?.collaborators ?? {};
70
+ const status = retailerStatus(
71
+ servicesData,
72
+ item.service_name,
73
+ activeRetailerId,
74
+ product[`${item.service_name}_status`]
75
+ );
76
+ return {
77
+ ...definition,
78
+ status,
79
+ tab: item.tab,
80
+ assignees: {
81
+ especialist: findUser(collaborators, product[`id_${item.service_name}_especialist`]),
82
+ facilitator: findUser(collaborators, product[`id_${item.service_name}_facilitator`]),
83
+ auditor: product.id_auditor
84
+ ? findUser({ ...collaborators, auditors: auditors ?? [] }, product.id_auditor)
85
+ : null,
86
+ },
87
+ mine:
88
+ rolesInService(product, user, item.service_name).length > 0 ||
89
+ isAssignedToService(product, user, item.service_name) ||
90
+ isAuditorAssigned(product, user),
91
+ pending: isPendingFor(product, user, item.service_name, status),
92
+ is_demo: false,
93
+ };
94
+ });
95
+
96
+ // Solo historias (`demoServices`): el auditor del producto lo es de todos sus
97
+ // servicios, así «Míos» de un auditor no esconde los que no tienen editor.
98
+ const productAuditor = product.id_auditor ? findUser({ auditors: auditors ?? [] }, product.id_auditor) : null;
99
+ const auditorIsMe = isAuditorAssigned(product, user);
100
+
101
+ const demo = (demoServices ?? []).map((service) => ({
102
+ ...catalogEntry(service.service_name),
103
+ display_name: service.display_name ?? catalogEntry(service.service_name).display_name ?? service.service_name,
104
+ status: service.status,
105
+ tab: null,
106
+ assignees: { especialist: null, facilitator: null, auditor: productAuditor },
107
+ mine: auditorIsMe,
108
+ pending: false,
109
+ is_demo: true,
110
+ }));
111
+
112
+ return [...classic, ...demo];
113
+ };
114
+
115
+ /* ------------------------------------------------------------------------ */
116
+ /* Desde el backend (GET /product-services) */
117
+ /* ------------------------------------------------------------------------ */
118
+
119
+ /*
120
+ Los servicios del producto como los devuelve GET /product-services: leídos
121
+ de las columnas del catálogo de rel_order_services (id_service, id_status,
122
+ id_especialist, id_facilitator). Trae todos los servicios del producto, los
123
+ de siempre y los del catálogo, con su estatus por cadena, su equipo y lo que
124
+ la persona puede hacer con cada uno (`actions`).
125
+
126
+ Cuando esta lista está, la edición la usa en lugar de armar los servicios con
127
+ los campos del producto (buildEditionServices).
128
+ */
129
+
130
+ /** En qué estatus le toca actuar a cada rol (flujo completo, el del motor). */
131
+ const WORKFLOW_ACTIONABLE = {
132
+ especialist: ["AS", "CA", "RC", "RA"],
133
+ facilitator: ["PA", "IE"],
134
+ auditor: ["AC", "RP", "RCA", "SAC"],
135
+ };
136
+
137
+ /** Roles de la persona en el servicio, según su equipo. */
138
+ const rolesOfUser = (assignees, userId) =>
139
+ Object.entries(assignees ?? {})
140
+ .filter(([, person]) => person && userId && person.id_user === userId)
141
+ .map(([role]) => role);
142
+
143
+ /**
144
+ * @param {{ productServices: { services: Array }, user: Object, activeRetailerId?: number }} params
145
+ * @returns {Array} servicios con la forma de la lista lateral, más `api` (el
146
+ * servicio tal como llegó) para los paneles que lo necesitan.
147
+ */
148
+ export const buildServicesFromApi = ({ productServices, user, activeRetailerId }) => {
149
+ const userId = user?.id_user;
150
+ return (productServices?.services ?? []).map((service) => {
151
+ const entry = catalogEntry(service.service_name);
152
+ const flowType = service.status_flow?.flow_type ?? "workflow";
153
+ const simple = flowType === "simple";
154
+ // Estatus de la cadena activa; si el servicio no es por cadena (o no está
155
+ // en ella), el general.
156
+ const status =
157
+ service.retailers?.find((retailer) => retailer.id_retailer === activeRetailerId)?.status ??
158
+ service.status ??
159
+ null;
160
+ const assignees = service.assignees ?? {};
161
+ const roles = rolesOfUser(assignees, userId);
162
+ const parts = String(status?.status_name ?? "").split("/");
163
+ const pending = simple
164
+ ? roles.includes("especialist") && Boolean(status) && !status.is_final
165
+ : roles.some((role) => parts.some((part) => WORKFLOW_ACTIONABLE[role]?.includes(part)));
166
+
167
+ return {
168
+ ...entry,
169
+ service_name: service.service_name,
170
+ display_name: service.display_name ?? entry.display_name ?? service.service_name,
171
+ group: service.category?.category_name ?? entry.group,
172
+ view: simple ? "simple" : entry.view,
173
+ icon: service.icon_name ?? entry.icon,
174
+ status: status?.status_name ?? null,
175
+ status_label: status?.display_name ?? null,
176
+ status_color: status?.color ?? null,
177
+ status_flow_type: flowType,
178
+ tab: tabOf(service.service_name),
179
+ assignees,
180
+ mine: roles.length > 0,
181
+ pending,
182
+ is_demo: false,
183
+ is_catalog: !service.is_legacy,
184
+ api: service,
185
+ };
186
+ });
187
+ };
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Lista lateral de la edición a partir de GET /product-services.
3
+ *
4
+ * npx vitest run src/components/pages/ProductEdition
5
+ */
6
+ import { describe, expect, it } from "vitest";
7
+ import { buildServicesFromApi } from "./services";
8
+
9
+ const person = (id_user, name) => ({ id_user, name, last_name: "Prueba" });
10
+ const status = (status_name, extra = {}) => ({ status_name, display_name: status_name, color: null, ...extra });
11
+
12
+ const productServices = {
13
+ article_id: 10,
14
+ order_id: 500,
15
+ services: [
16
+ {
17
+ service_name: "datasheet",
18
+ display_name: "Ficha técnica",
19
+ category: { category_name: "texts" },
20
+ status_flow: { flow_name: "workflow", flow_type: "workflow" },
21
+ is_legacy: true,
22
+ status: status("PA"),
23
+ retailers: [
24
+ { id_retailer: 58, status: status("PA") },
25
+ { id_retailer: 59, status: status("RA/AS") },
26
+ ],
27
+ roles: ["especialist", "facilitator", "auditor"],
28
+ assignees: { especialist: person(426, "Textos"), facilitator: person(901, "Coord"), auditor: person(903, "QA") },
29
+ actions: { set_status: false, assign: false },
30
+ },
31
+ {
32
+ service_name: "productPurchase",
33
+ display_name: "Compra de producto",
34
+ icon_name: null,
35
+ category: { category_name: "logistics" },
36
+ status_flow: { flow_name: "checkbox", flow_type: "simple" },
37
+ is_legacy: false,
38
+ per_retailer: false,
39
+ status: status("PE", { display_name: "Pendiente", color: "#FFAA00", is_final: false }),
40
+ retailers: [{ id_retailer: 0, status: status("PE", { display_name: "Pendiente", color: "#FFAA00", is_final: false }) }],
41
+ status_options: [status("PE"), status("HE", { is_final: true })],
42
+ roles: ["especialist"],
43
+ assignees: { especialist: person(426, "Textos"), facilitator: null, auditor: null },
44
+ actions: { set_status: true, assign: true },
45
+ },
46
+ ],
47
+ };
48
+
49
+ describe("buildServicesFromApi", () => {
50
+ const specialist = { id_user: 426, id_role: 7 };
51
+
52
+ it("toma el estatus de la cadena activa; si no está en ella, el general", () => {
53
+ const [datasheet, purchase] = buildServicesFromApi({ productServices, user: specialist, activeRetailerId: 59 });
54
+ expect(datasheet.status).toBe("RA/AS");
55
+ expect(purchase.status).toBe("PE");
56
+ });
57
+
58
+ it("los de siempre conservan su pestaña; los del catálogo usan su bloque y la vista simple", () => {
59
+ const [datasheet, purchase] = buildServicesFromApi({ productServices, user: specialist, activeRetailerId: 58 });
60
+ expect(datasheet).toMatchObject({ tab: "Ficha técnica", group: "texts", is_catalog: false });
61
+ expect(purchase).toMatchObject({
62
+ tab: null,
63
+ group: "logistics",
64
+ view: "simple",
65
+ icon: "purchase",
66
+ status_label: "Pendiente",
67
+ status_color: "#FFAA00",
68
+ status_flow_type: "simple",
69
+ is_catalog: true,
70
+ });
71
+ expect(purchase.api).toBe(productServices.services[1]);
72
+ });
73
+
74
+ it("«Míos» y «te toca» salen del equipo del servicio", () => {
75
+ const [datasheet, purchase] = buildServicesFromApi({ productServices, user: specialist, activeRetailerId: 59 });
76
+ // RA/AS: le toca a la especialista asignada.
77
+ expect(datasheet).toMatchObject({ mine: true, pending: true });
78
+ // Flujo simple sin terminar: le toca a su especialista.
79
+ expect(purchase).toMatchObject({ mine: true, pending: true });
80
+
81
+ const [, other] = buildServicesFromApi({ productServices, user: { id_user: 1 }, activeRetailerId: 59 });
82
+ expect(other).toMatchObject({ mine: false, pending: false });
83
+ });
84
+
85
+ it("sin respuesta del backend, lista vacía", () => {
86
+ expect(buildServicesFromApi({ productServices: null, user: specialist })).toEqual([]);
87
+ });
88
+ });
@@ -1,4 +1,5 @@
1
1
  import { RetailerProductEdition } from "./index";
2
+ import { withDemoServices } from "../../organisms/ServicesWorkspace/demo";
2
3
 
3
4
  export default {
4
5
  title: "Components/pages/RetailerProductEdition",
@@ -217,3 +218,14 @@ user: {
217
218
  }
218
219
 
219
220
  };
221
+
222
+ /**
223
+ * Con la pestaña "Servicios" (la activa Creators con `showServicesTab`) y
224
+ * servicios de ejemplo además de los tres de siempre.
225
+ */
226
+ export const ConPestanaServicios = Template.bind({});
227
+ ConPestanaServicios.args = {
228
+ ...RetailerProductEditionDefault.args,
229
+ showServicesTab: true,
230
+ productSelected: withDemoServices(RetailerProductEditionDefault.args.productSelected, 14),
231
+ };
@@ -261,8 +261,12 @@ export const ProviderProductEditionProvider = ({ children }) => {
261
261
  const updateImages = async (token) => {
262
262
  // Imágenes/videos recién agregados a la galería (aún sin id en BD).
263
263
  const newImages = state.images_values?.values?.filter((e) => !e.id) || [];
264
- // Salir solo si no hay nada que crear ni que actualizar.
265
- if (state.updated_images_values.length === 0 && newImages.length === 0)
264
+ // Salir solo si no hay nada que crear, actualizar, ni atributos de imagen pendientes.
265
+ if (
266
+ state.updated_images_values.length === 0 &&
267
+ newImages.length === 0 &&
268
+ state.updated_attr_for_imgs.length === 0
269
+ )
266
270
  return;
267
271
  const updatedImages = state.updated_images_values;
268
272