contentoh-components-library 21.6.60 → 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 (76) 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/index.js +57 -40
  34. package/dist/components/pages/RetailerProductEdition/utils.js +6 -2
  35. package/dist/index.js +13 -0
  36. package/package.json +1 -1
  37. package/src/components/organisms/ServicesNavigation/ServiceToolbar.jsx +143 -0
  38. package/src/components/organisms/ServicesNavigation/ServicesNavigation.stories.js +101 -0
  39. package/src/components/organisms/ServicesNavigation/ServicesRail.jsx +591 -0
  40. package/src/components/organisms/ServicesNavigation/catalog.js +180 -0
  41. package/src/components/organisms/ServicesNavigation/index.jsx +232 -0
  42. package/src/components/organisms/ServicesNavigation/model.js +85 -0
  43. package/src/components/organisms/ServicesNavigation/ui.jsx +229 -0
  44. package/src/components/organisms/ServicesNavigation/views.jsx +705 -0
  45. package/src/components/organisms/ServicesWorkspace/AssigneePicker.js +348 -0
  46. package/src/components/organisms/ServicesWorkspace/ServicesWorkspace.stories.js +61 -0
  47. package/src/components/organisms/ServicesWorkspace/demo.js +58 -0
  48. package/src/components/organisms/ServicesWorkspace/index.js +556 -0
  49. package/src/components/organisms/ServicesWorkspace/servicesModel.js +203 -0
  50. package/src/components/organisms/ServicesWorkspace/tooltipStyles.js +27 -0
  51. package/src/components/pages/CreatorsProductEdition/mocks/mockApi.js +17 -0
  52. package/src/components/pages/CreatorsProductEdition/mocks/productServices.fixtures.js +175 -0
  53. package/src/components/pages/CreatorsProductEdition/sections/StatusChip.jsx +8 -4
  54. package/src/components/pages/ProductEdition/ProductEdition.stories.js +106 -0
  55. package/src/components/pages/ProductEdition/ProductEditionView.jsx +1142 -0
  56. package/src/components/pages/ProductEdition/ServiceHeader.jsx +104 -0
  57. package/src/components/pages/ProductEdition/api.js +84 -0
  58. package/src/components/pages/ProductEdition/index.js +21 -0
  59. package/src/components/pages/ProductEdition/permissions.js +128 -0
  60. package/src/components/pages/ProductEdition/permissions.test.js +249 -0
  61. package/src/components/pages/ProductEdition/sections/CatalogServicePanel.jsx +123 -0
  62. package/src/components/pages/ProductEdition/sections/EditionTopBar.jsx +134 -0
  63. package/src/components/pages/ProductEdition/sections/ImagePanel.jsx +342 -0
  64. package/src/components/pages/ProductEdition/sections/ServiceTeam.jsx +184 -0
  65. package/src/components/pages/ProductEdition/sections/ServiceTools.jsx +134 -0
  66. package/src/components/pages/ProductEdition/services.js +187 -0
  67. package/src/components/pages/ProductEdition/services.test.js +88 -0
  68. package/src/components/pages/RetailerProductEdition/RetailerProductEdition.stories.js +12 -0
  69. package/src/components/pages/RetailerProductEdition/index.js +28 -3
  70. package/src/components/pages/RetailerProductEdition/utils.js +5 -0
  71. package/src/index.js +1 -0
  72. package/.claude/settings.local.json +0 -7
  73. package/dist/ai/prompts/attribute.prompt.js +0 -28
  74. package/dist/ai/prompts/description.prompt.js +0 -33
  75. package/dist/ai/schemas/attribute.schema.js +0 -35
  76. 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
+ };
@@ -1,4 +1,4 @@
1
- import { useEffect, useRef, useState } from "react";
1
+ import { useEffect, useMemo, useRef, useState } from "react";
2
2
 
3
3
  import axios from "axios";
4
4
  import { useDropzone } from "react-dropzone";
@@ -74,6 +74,7 @@ import generateThumbnail from "./generateThumnail";
74
74
 
75
75
  import { AiProductEditionProvider } from "../../../contexts/AiProductEdition";
76
76
  import ChangeStatusModal from "../../organisms/ChangeStatusModal";
77
+ import ServicesWorkspace from "../../organisms/ServicesWorkspace";
77
78
 
78
79
  import { Skeleton, Box, Stack } from "@mui/material";
79
80
 
@@ -105,8 +106,14 @@ function obtenerDuracionVideoBase64(base64Data) {
105
106
  });
106
107
  }
107
108
 
109
+ /** Pestaña con todos los servicios del producto (ver organisms/ServicesWorkspace). */
110
+ const SERVICES_TAB = "Servicios";
111
+
108
112
  const RetailerProductEditionView = ({
109
113
  tabsSections,
114
+ // Opcional para no cambiar la edición de quien no la pida (aplicación de
115
+ // proveedores). Creators la activa.
116
+ showServicesTab = false,
110
117
  productSelected = {},
111
118
  user = {},
112
119
  token,
@@ -134,6 +141,13 @@ const RetailerProductEditionView = ({
134
141
 
135
142
  const isRetailer = user?.is_retailer;
136
143
 
144
+ const tabs = useMemo(
145
+ () =>
146
+ showServicesTab ? { ...tabsSections, [SERVICES_TAB]: false } : tabsSections,
147
+ [tabsSections, showServicesTab],
148
+ );
149
+ const isServicesTab = state.active_tab === SERVICES_TAB;
150
+
137
151
  const [showVersionSelector, setShowVersionSelector] =
138
152
  useCloseModal("version-selector");
139
153
  const [auditableVersion, setAuditableVersion] = useState(null);
@@ -2928,7 +2942,7 @@ const RetailerProductEditionView = ({
2928
2942
  }
2929
2943
  />
2930
2944
  <FullTabsMenu
2931
- tabsSections={tabsSections}
2945
+ tabsSections={tabs}
2932
2946
  status={getStatusByCurrentServiceAndRetailer()}
2933
2947
  activeTab={state.active_tab}
2934
2948
  isRetailer={isRetailer}
@@ -2975,6 +2989,14 @@ const RetailerProductEditionView = ({
2975
2989
  <Loading />
2976
2990
  ) : (
2977
2991
  <>
2992
+ {isServicesTab && (
2993
+ <ServicesWorkspace
2994
+ product={state.product}
2995
+ retailers={state.product?.categoryRetailerInOrder ?? []}
2996
+ canAssign={![7, 8].includes(user.id_role)}
2997
+ onOpenEditor={handleOnChangeActiveTab}
2998
+ />
2999
+ )}
2978
3000
  {state.active_tab === "Descripción" &&
2979
3001
  (state.product?.description_status !== "NS" ? (
2980
3002
  <InputGroup
@@ -3150,7 +3172,10 @@ const RetailerProductEditionView = ({
3150
3172
  </>
3151
3173
  )}
3152
3174
  </div>
3153
- {(isUserAssignedToService(state.active_tab) ||
3175
+ {/* Comentario y cambio de estatus son de un servicio con editor;
3176
+ en la pestaña Servicios cada servicio lleva su propio detalle. */}
3177
+ {!isServicesTab &&
3178
+ (isUserAssignedToService(state.active_tab) ||
3154
3179
  isAuditorAssigned()) &&
3155
3180
  state.product[`${getConceptByTab(state.active_tab)}_status`] !==
3156
3181
  "NS" && (