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,705 @@
1
+ import { useState } from "react";
2
+ import { Box, Button, ButtonBase, LinearProgress, Stack, TextField, Typography } from "@mui/material";
3
+ import ImageOutlinedIcon from "@mui/icons-material/ImageOutlined";
4
+ import CloudUploadOutlinedIcon from "@mui/icons-material/CloudUploadOutlined";
5
+ import PlayCircleOutlineIcon from "@mui/icons-material/PlayCircleOutline";
6
+ import InsertDriveFileOutlinedIcon from "@mui/icons-material/InsertDriveFileOutlined";
7
+ import LockOutlinedIcon from "@mui/icons-material/LockOutlined";
8
+ import ErrorOutlineIcon from "@mui/icons-material/ErrorOutline";
9
+ import ArrowForwardIcon from "@mui/icons-material/ArrowForward";
10
+
11
+ import {
12
+ COLORS,
13
+ FONT_STACK,
14
+ TITLE_STACK,
15
+ PendingDot,
16
+ ServiceIcon,
17
+ StatusChip,
18
+ serviceStatusProps,
19
+ captionSx,
20
+ fieldSx,
21
+ labelSx,
22
+ outlineButtonSx,
23
+ statusColor,
24
+ serviceStatusColor,
25
+ statusName,
26
+ } from "./ui";
27
+ import { SUMMARY, fullName, groupServices, isPendingFor } from "./model";
28
+
29
+ /*
30
+ Vistas de trabajo del prototipo. Una por tipo de entregable, no una por
31
+ servicio: Descripción, SEO y Header de ficha usan la de textos; Foto, Edición
32
+ y Resize, la de imágenes. El contenido es de ejemplo: lo que se muestra es
33
+ cómo se navega y qué ve cada quien, no el editor final.
34
+ */
35
+
36
+ const baseStatus = (status) => String(status ?? "").split("/")[0];
37
+ const isRejected = (status) =>
38
+ String(status ?? "")
39
+ .split("/")
40
+ .some((part) => ["RA", "RC", "RP", "RCA", "RAC"].includes(part));
41
+ const DONE = ["Ex", "FAP"];
42
+
43
+ /* ------------------------------------------------------------------------ */
44
+ /* Piezas comunes */
45
+ /* ------------------------------------------------------------------------ */
46
+
47
+ const Panel = ({ title, aside, children, sx }) => (
48
+ <Box
49
+ sx={{
50
+ border: `1px solid ${COLORS.s2}`,
51
+ borderRadius: "10px",
52
+ bgcolor: COLORS.white,
53
+ p: 2,
54
+ mb: 2,
55
+ ...sx,
56
+ }}
57
+ >
58
+ {(title || aside) && (
59
+ <Stack direction="row" alignItems="baseline" spacing={1} sx={{ mb: 1.5, flexWrap: "wrap", rowGap: 0.5 }}>
60
+ {title && (
61
+ <Typography sx={{ fontFamily: TITLE_STACK, fontWeight: 600, fontSize: 15, color: COLORS.s5 }}>
62
+ {title}
63
+ </Typography>
64
+ )}
65
+ <Box sx={{ flex: 1 }} />
66
+ {aside}
67
+ </Stack>
68
+ )}
69
+ {children}
70
+ </Box>
71
+ );
72
+
73
+ const Note = ({ icon, tone = "info", children }) => (
74
+ <Box
75
+ sx={{
76
+ display: "flex",
77
+ alignItems: "flex-start",
78
+ gap: 1,
79
+ p: 1.5,
80
+ mb: 2,
81
+ borderRadius: "8px",
82
+ bgcolor: tone === "error" ? "rgba(215, 77, 237, 0.08)" : COLORS.blue_light,
83
+ border: `1px solid ${tone === "error" ? "rgba(215, 77, 237, 0.35)" : COLORS.s2}`,
84
+ }}
85
+ >
86
+ <Box sx={{ color: tone === "error" ? COLORS.rejected_status : COLORS.s4, display: "flex", mt: "1px" }}>{icon}</Box>
87
+ <Typography sx={{ fontFamily: FONT_STACK, fontSize: 13, lineHeight: "19px", color: COLORS.s5 }}>{children}</Typography>
88
+ </Box>
89
+ );
90
+
91
+ const REJECTION_COMMENTS = {
92
+ description: "Falta mencionar la garantía en la descripción larga.",
93
+ edition: "La toma de empaque tiene sombra: el fondo debe ser blanco puro.",
94
+ aplusPlatinum: "El banner principal no respeta la guía de marca de la cadena.",
95
+ };
96
+
97
+ const RejectionNote = ({ service }) =>
98
+ isRejected(service.status) ? (
99
+ <Note icon={<ErrorOutlineIcon sx={{ fontSize: 18 }} />} tone="error">
100
+ <b>{statusName(service.status) ?? "Rechazado"}.</b>{" "}
101
+ «{REJECTION_COMMENTS[service.service_name] ?? "Revisa los comentarios y vuelve a enviar."}»
102
+ </Note>
103
+ ) : null;
104
+
105
+ const DropZone = ({ label, hint, sx }) => (
106
+ <ButtonBase
107
+ sx={{
108
+ width: "100%",
109
+ minHeight: 120,
110
+ display: "flex",
111
+ flexDirection: "column",
112
+ gap: 0.75,
113
+ border: `1.5px dashed ${COLORS.magenta_s2}`,
114
+ borderRadius: "10px",
115
+ bgcolor: "rgba(227, 58, 169, 0.04)",
116
+ color: COLORS.magenta_s2,
117
+ p: 2,
118
+ "&:hover": { bgcolor: "rgba(227, 58, 169, 0.08)" },
119
+ ...sx,
120
+ }}
121
+ >
122
+ <CloudUploadOutlinedIcon sx={{ fontSize: 28 }} />
123
+ <Typography sx={{ fontFamily: FONT_STACK, fontSize: 13, fontWeight: 700 }}>{label}</Typography>
124
+ {hint && <Typography sx={{ ...captionSx, textAlign: "center" }}>{hint}</Typography>}
125
+ </ButtonBase>
126
+ );
127
+
128
+ const CountedField = ({ label, value: initial = "", max, rows = 1, required = false, placeholder }) => {
129
+ const [value, setValue] = useState(initial);
130
+ const over = max && value.length > max;
131
+ return (
132
+ <Box>
133
+ {label && (
134
+ <Typography sx={labelSx}>
135
+ {label}
136
+ {required && <Box component="span" sx={{ color: COLORS.magenta_s2 }}> *</Box>}
137
+ </Typography>
138
+ )}
139
+ <TextField
140
+ fullWidth
141
+ size="small"
142
+ multiline={rows > 1}
143
+ minRows={rows > 1 ? rows : undefined}
144
+ value={value}
145
+ placeholder={placeholder}
146
+ onChange={(event) => setValue(event.target.value)}
147
+ sx={fieldSx}
148
+ />
149
+ {max && (
150
+ <Typography sx={{ ...captionSx, textAlign: "right", mt: 0.25, color: over ? COLORS.rejected_status : COLORS.s4 }}>
151
+ {value.length} / {max}
152
+ </Typography>
153
+ )}
154
+ </Box>
155
+ );
156
+ };
157
+
158
+ /* ------------------------------------------------------------------------ */
159
+ /* Resumen */
160
+ /* ------------------------------------------------------------------------ */
161
+
162
+ const StatTile = ({ value, label, color }) => (
163
+ <Box sx={{ flex: "1 1 120px", border: `1px solid ${COLORS.s2}`, borderRadius: "10px", p: 1.5, bgcolor: COLORS.white }}>
164
+ <Stack direction="row" alignItems="center" spacing={0.75}>
165
+ {color && <Box sx={{ width: 8, height: 8, borderRadius: "50%", bgcolor: color }} />}
166
+ <Typography sx={{ fontFamily: TITLE_STACK, fontWeight: 600, fontSize: 24, lineHeight: "30px", color: COLORS.s5 }}>
167
+ {value}
168
+ </Typography>
169
+ </Stack>
170
+ <Typography sx={captionSx}>{label}</Typography>
171
+ </Box>
172
+ );
173
+
174
+ export const SummaryView = ({ services, userId, onSelect }) => {
175
+ const pending = services.filter((service) => isPendingFor(service, userId)).length;
176
+ const rejected = services.filter((service) => isRejected(service.status)).length;
177
+ const done = services.filter((service) => DONE.includes(baseStatus(service.status))).length;
178
+
179
+ return (
180
+ <>
181
+ <Stack direction="row" spacing={1.5} sx={{ mb: 2, flexWrap: "wrap", rowGap: 1.5 }}>
182
+ <StatTile value={services.length} label="Servicios contratados" />
183
+ <StatTile value={pending} label="Te tocan a ti" color={COLORS.magenta_s2} />
184
+ <StatTile value={rejected} label="Rechazados" color={COLORS.rejected_status} />
185
+ <StatTile value={done} label="Terminados" color={COLORS.exported} />
186
+ </Stack>
187
+
188
+ <Panel title="Avance">
189
+ <Box aria-hidden="true" sx={{ display: "flex", gap: "3px", height: 10 }}>
190
+ {groupServices(services)
191
+ .flatMap((group) => group.services)
192
+ .map((service) => (
193
+ <Box
194
+ key={service.service_name}
195
+ title={`${service.display_name} · ${statusName(service.status) ?? service.status}`}
196
+ sx={{ flex: "1 1 0", minWidth: 4, borderRadius: "3px", bgcolor: serviceStatusColor(service) }}
197
+ />
198
+ ))}
199
+ </Box>
200
+ <Typography sx={{ ...captionSx, mt: 0.75 }}>
201
+ Cada segmento es un servicio, con el color de su estatus, en el orden de la lista.
202
+ </Typography>
203
+ </Panel>
204
+
205
+ {groupServices(services).map((group) => (
206
+ <Panel key={group.key} title={group.label} aside={<Typography sx={captionSx}>{group.services.length}</Typography>} sx={{ p: 1, pt: 1.5 }}>
207
+ {group.services.map((service) => {
208
+ const especialist = service.assignees?.especialist;
209
+ const mine = isPendingFor(service, userId);
210
+ return (
211
+ <ButtonBase
212
+ key={service.service_name}
213
+ onClick={() => onSelect(service.service_name)}
214
+ sx={{
215
+ width: "100%",
216
+ display: "grid",
217
+ gridTemplateColumns: {
218
+ xs: "minmax(0, 1fr) auto",
219
+ sm: "minmax(0, 1.6fr) 72px minmax(0, 1fr) 16px 84px 20px",
220
+ },
221
+ alignItems: "center",
222
+ columnGap: 1.5,
223
+ px: 1,
224
+ py: 1,
225
+ borderRadius: "8px",
226
+ textAlign: "left",
227
+ "&:hover": { bgcolor: COLORS.s1 },
228
+ "& + &": { borderTop: `1px solid ${COLORS.s2}` },
229
+ }}
230
+ >
231
+ <Stack direction="row" alignItems="center" spacing={1} sx={{ minWidth: 0 }}>
232
+ <ServiceIcon icon={service.icon} sx={{ color: COLORS.s4 }} />
233
+ <Typography noWrap sx={{ fontFamily: FONT_STACK, fontSize: 14, color: COLORS.color_gray }}>
234
+ {service.display_name}
235
+ </Typography>
236
+ </Stack>
237
+ <Box>
238
+ <StatusChip {...serviceStatusProps(service)} oval />
239
+ </Box>
240
+ <Typography noWrap sx={{ ...captionSx, display: { xs: "none", sm: "block" } }}>
241
+ {especialist ? fullName(especialist) : "Sin especialista"}
242
+ </Typography>
243
+ <Box sx={{ display: { xs: "none", sm: "flex" } }}>{mine && <PendingDot />}</Box>
244
+ <Typography sx={{ ...captionSx, display: { xs: "none", sm: "block" } }}>{service.updated_at}</Typography>
245
+ <ArrowForwardIcon sx={{ display: { xs: "none", sm: "block" }, fontSize: 16, color: COLORS.s3 }} />
246
+ </ButtonBase>
247
+ );
248
+ })}
249
+ </Panel>
250
+ ))}
251
+ </>
252
+ );
253
+ };
254
+
255
+ /* ------------------------------------------------------------------------ */
256
+ /* Ficha técnica */
257
+ /* ------------------------------------------------------------------------ */
258
+
259
+ const ATTRIBUTES = [
260
+ { label: "Marca", value: "Norteña Pro", required: true },
261
+ { label: "Modelo", value: "NP-2040", required: true },
262
+ { label: "Voltaje", value: "20 V", required: true },
263
+ { label: "Tipo de batería", value: "Ion de litio", required: true },
264
+ { label: "Velocidad máxima", value: "1 500 rpm" },
265
+ { label: "Peso", value: "1.4 kg" },
266
+ { label: "Garantía", value: "2 años", required: true },
267
+ { label: "Contenido de la caja", value: "", required: true },
268
+ ];
269
+
270
+ const AttributesView = ({ service, retailerName }) => {
271
+ const required = ATTRIBUTES.filter((item) => item.required);
272
+ const filled = required.filter((item) => item.value).length;
273
+ return (
274
+ <>
275
+ <RejectionNote service={service} />
276
+ <Panel
277
+ title={`Atributos de ${retailerName}`}
278
+ aside={
279
+ <Typography sx={captionSx}>
280
+ {filled} de {required.length} requeridos completos
281
+ </Typography>
282
+ }
283
+ >
284
+ <Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 2 }}>
285
+ {ATTRIBUTES.map((item) => (
286
+ <CountedField key={item.label} label={item.label} value={item.value} required={item.required} />
287
+ ))}
288
+ </Box>
289
+ </Panel>
290
+ </>
291
+ );
292
+ };
293
+
294
+ /* ------------------------------------------------------------------------ */
295
+ /* Textos (Descripción, SEO, Header de ficha) */
296
+ /* ------------------------------------------------------------------------ */
297
+
298
+ const LONG_DESCRIPTION =
299
+ "Perfora madera, metal y plástico sin cables. El motor de 20 V llega a 1 500 rpm y el embrague de 21 posiciones evita barrir tornillos. Incluye dos baterías de ion de litio de 2 Ah, cargador rápido y estuche.";
300
+
301
+ const TEXT_FIELDS = {
302
+ description: [
303
+ { label: "Descripción corta", max: 150, rows: 2, required: true, value: "Taladro inalámbrico de 20 V con dos baterías de ion de litio y cargador rápido." },
304
+ { label: "Descripción larga", max: 1000, rows: 5, required: true, value: LONG_DESCRIPTION },
305
+ { label: "Puntos clave", max: 400, rows: 3, value: "• Motor de 1 500 rpm\n• Dos baterías de 2 Ah\n• Luz LED de trabajo" },
306
+ ],
307
+ datasheetHeader: [
308
+ { label: "Encabezado de la ficha", max: 120, rows: 2, required: true, value: "Taladro inalámbrico 20 V | Norteña Pro NP-2040" },
309
+ ],
310
+ seo: [
311
+ { label: "Título SEO", max: 60, required: true, value: "Taladro inalámbrico 20 V con 2 baterías | Norteña Pro" },
312
+ { label: "Meta descripción", max: 155, rows: 3, required: true, value: "", placeholder: "Resumen para buscadores" },
313
+ { label: "Palabras clave", max: 200, rows: 2, value: "taladro inalámbrico, taladro 20v, herramienta eléctrica" },
314
+ ],
315
+ };
316
+
317
+ const TextView = ({ service, retailerName }) => (
318
+ <>
319
+ <RejectionNote service={service} />
320
+ <Panel title={`${service.display_name} para ${retailerName}`}>
321
+ <Stack spacing={2}>
322
+ {(TEXT_FIELDS[service.service_name] ?? TEXT_FIELDS.description).map((field) => (
323
+ <CountedField key={field.label} {...field} />
324
+ ))}
325
+ </Stack>
326
+ </Panel>
327
+ </>
328
+ );
329
+
330
+ /* ------------------------------------------------------------------------ */
331
+ /* Traducción */
332
+ /* ------------------------------------------------------------------------ */
333
+
334
+ const TRANSLATION_ROWS = [
335
+ { label: "Nombre del producto", source: "Taladro inalámbrico 20 V con 2 baterías", target: "20V Cordless Drill with 2 Batteries" },
336
+ { label: "Descripción corta", source: "Taladro inalámbrico de 20 V con dos baterías de ion de litio y cargador rápido.", target: "20V cordless drill with two lithium-ion batteries and a fast charger." },
337
+ { label: "Descripción larga", source: LONG_DESCRIPTION, target: "" },
338
+ { label: "Puntos clave", source: "• Motor de 1 500 rpm\n• Dos baterías de 2 Ah\n• Luz LED de trabajo", target: "" },
339
+ { label: "Contenido de la caja", source: "Taladro, 2 baterías, cargador y estuche", target: "Drill, 2 batteries, charger and carrying case" },
340
+ ];
341
+
342
+ const LanguagePill = ({ children, strong }) => (
343
+ <Box
344
+ component="span"
345
+ sx={{
346
+ px: 1.25,
347
+ height: 26,
348
+ display: "inline-flex",
349
+ alignItems: "center",
350
+ borderRadius: "13px",
351
+ fontFamily: FONT_STACK,
352
+ fontSize: 12,
353
+ fontWeight: 700,
354
+ bgcolor: strong ? COLORS.s5 : COLORS.s2,
355
+ color: strong ? COLORS.white : COLORS.s5,
356
+ }}
357
+ >
358
+ {children}
359
+ </Box>
360
+ );
361
+
362
+ const TranslationView = ({ service, hasSourceServices }) => {
363
+ const done = TRANSLATION_ROWS.filter((row) => row.target).length;
364
+ return (
365
+ <>
366
+ <RejectionNote service={service} />
367
+ <Note icon={<LockOutlinedIcon sx={{ fontSize: 18 }} />}>
368
+ El texto original viene de la Descripción y la Ficha técnica del producto y aquí solo se lee.
369
+ {hasSourceServices
370
+ ? " Si hay que corregirlo, se hace en esos servicios."
371
+ : " Este producto no tiene esos servicios contratados: su contenido sirve de base, pero no aparecen en la lista."}
372
+ </Note>
373
+ <Panel
374
+ title={
375
+ <Stack direction="row" alignItems="center" spacing={1} component="span">
376
+ <LanguagePill>Español (México)</LanguagePill>
377
+ <ArrowForwardIcon sx={{ fontSize: 16, color: COLORS.s4 }} />
378
+ <LanguagePill strong>Inglés (EE. UU.)</LanguagePill>
379
+ </Stack>
380
+ }
381
+ aside={
382
+ <Box sx={{ minWidth: 150 }}>
383
+ <Typography sx={{ ...captionSx, textAlign: "right" }}>
384
+ {done} de {TRANSLATION_ROWS.length} campos traducidos
385
+ </Typography>
386
+ <LinearProgress
387
+ variant="determinate"
388
+ value={(done / TRANSLATION_ROWS.length) * 100}
389
+ sx={{
390
+ mt: 0.5,
391
+ height: 4,
392
+ borderRadius: 2,
393
+ bgcolor: COLORS.s2,
394
+ "& .MuiLinearProgress-bar": { bgcolor: COLORS.magenta_s2 },
395
+ }}
396
+ />
397
+ </Box>
398
+ }
399
+ >
400
+ <Box
401
+ sx={{
402
+ display: { xs: "none", md: "grid" },
403
+ gridTemplateColumns: "1fr 1fr",
404
+ columnGap: 2,
405
+ mb: 1,
406
+ }}
407
+ >
408
+ <Typography sx={{ ...captionSx, fontWeight: 700, textTransform: "uppercase", letterSpacing: "0.05em" }}>
409
+ Original · solo lectura
410
+ </Typography>
411
+ <Typography sx={{ ...captionSx, fontWeight: 700, textTransform: "uppercase", letterSpacing: "0.05em" }}>
412
+ Traducción
413
+ </Typography>
414
+ </Box>
415
+ <Stack spacing={2}>
416
+ {TRANSLATION_ROWS.map((row) => (
417
+ <Box key={row.label}>
418
+ <Typography sx={labelSx}>{row.label}</Typography>
419
+ <Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 1.5 }}>
420
+ <Box
421
+ sx={{
422
+ p: "8.5px 14px",
423
+ borderRadius: "6px",
424
+ bgcolor: COLORS.s1,
425
+ border: `1px solid ${COLORS.s2}`,
426
+ fontFamily: FONT_STACK,
427
+ fontSize: 14,
428
+ lineHeight: "23px",
429
+ color: COLORS.gray,
430
+ whiteSpace: "pre-line",
431
+ }}
432
+ >
433
+ {row.source}
434
+ </Box>
435
+ <CountedField
436
+ label=""
437
+ value={row.target}
438
+ rows={row.source.length > 80 || row.source.includes("\n") ? 3 : 1}
439
+ placeholder="Pendiente de traducir"
440
+ />
441
+ </Box>
442
+ </Box>
443
+ ))}
444
+ </Stack>
445
+ </Panel>
446
+ </>
447
+ );
448
+ };
449
+
450
+ /* ------------------------------------------------------------------------ */
451
+ /* Imágenes (Imágenes, Foto, Edición, Resize, Adaptaciones, Imagen premium) */
452
+ /* ------------------------------------------------------------------------ */
453
+
454
+ const SHOTS = ["Frente", "Reverso", "Lateral", "Empaque", "En uso", "Detalle", "Ambiente", "Medidas"];
455
+
456
+ const TILE_STATES = {
457
+ ready: { label: "Lista", color: COLORS.finished },
458
+ pending: { label: "Pendiente", color: COLORS.s4 },
459
+ rejected: { label: "Rechazada", color: COLORS.rejected_status },
460
+ };
461
+
462
+ const tileState = (service, index) => {
463
+ const base = baseStatus(service.status);
464
+ if (["Ex", "FAP", "AA", "AP", "AC", "ACA", "AAC"].includes(base)) return "ready";
465
+ if (["PA", "R"].includes(base)) return "pending";
466
+ if (isRejected(service.status) && index === 3) return "rejected";
467
+ return index < 5 ? "ready" : "pending";
468
+ };
469
+
470
+ const ImagesView = ({ service, imageServices, onSelect, userId }) => {
471
+ const states = SHOTS.map((_, index) => tileState(service, index));
472
+ const ready = states.filter((state) => state === "ready").length;
473
+ return (
474
+ <>
475
+ {imageServices.length > 1 && (
476
+ <Box sx={{ mb: 2 }}>
477
+ <Typography sx={{ ...captionSx, mb: 1 }}>
478
+ Las imágenes del producto son las mismas para {imageServices.length} servicios. Elige en cuál trabajas:
479
+ </Typography>
480
+ <Stack direction="row" spacing={1} sx={{ flexWrap: "wrap", rowGap: 1 }}>
481
+ {imageServices.map((item) => {
482
+ const active = item.service_name === service.service_name;
483
+ return (
484
+ <ButtonBase
485
+ key={item.service_name}
486
+ aria-pressed={active}
487
+ onClick={() => onSelect(item.service_name)}
488
+ sx={{
489
+ gap: 0.75,
490
+ height: 32,
491
+ px: 1.5,
492
+ borderRadius: "16px",
493
+ fontFamily: FONT_STACK,
494
+ fontSize: 13,
495
+ fontWeight: active ? 700 : 400,
496
+ color: active ? COLORS.magenta_s2 : COLORS.s5,
497
+ bgcolor: active ? COLORS.white : COLORS.s2,
498
+ border: `1px solid ${active ? COLORS.magenta_s2 : COLORS.s2}`,
499
+ }}
500
+ >
501
+ <ServiceIcon icon={item.icon} sx={{ fontSize: 16, color: "inherit" }} />
502
+ {item.display_name}
503
+ {/* Mismo orden que la lista lateral: «te toca» y luego el estatus. */}
504
+ {isPendingFor(item, userId) && <PendingDot size={7} withTooltip={false} />}
505
+ <StatusChip {...serviceStatusProps(item)} oval />
506
+ </ButtonBase>
507
+ );
508
+ })}
509
+ </Stack>
510
+ </Box>
511
+ )}
512
+
513
+ <RejectionNote service={service} />
514
+
515
+ <Panel
516
+ title={`${service.display_name} · ${SHOTS.length} tomas`}
517
+ aside={<Typography sx={captionSx}>{ready} listas para este servicio</Typography>}
518
+ >
519
+ <Box sx={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(132px, 1fr))", gap: 1.5 }}>
520
+ {SHOTS.map((shot, index) => {
521
+ const state = TILE_STATES[states[index]];
522
+ return (
523
+ <Box
524
+ key={shot}
525
+ sx={{
526
+ border: `1px solid ${states[index] === "rejected" ? COLORS.rejected_status : COLORS.s2}`,
527
+ borderRadius: "8px",
528
+ overflow: "hidden",
529
+ bgcolor: COLORS.white,
530
+ }}
531
+ >
532
+ <Box
533
+ sx={{
534
+ aspectRatio: "1 / 1",
535
+ display: "flex",
536
+ alignItems: "center",
537
+ justifyContent: "center",
538
+ background: `linear-gradient(135deg, ${COLORS.blue_light}, ${COLORS.s2})`,
539
+ }}
540
+ >
541
+ <ImageOutlinedIcon sx={{ fontSize: 34, color: COLORS.s3 }} />
542
+ </Box>
543
+ <Stack direction="row" alignItems="center" spacing={0.75} sx={{ px: 1, py: 0.75 }}>
544
+ <Typography noWrap sx={{ flex: 1, fontFamily: FONT_STACK, fontSize: 12, color: COLORS.color_gray }}>
545
+ {shot}
546
+ </Typography>
547
+ <Box sx={{ width: 7, height: 7, borderRadius: "50%", bgcolor: state.color }} />
548
+ <Typography sx={{ fontFamily: FONT_STACK, fontSize: 11, color: state.color }}>{state.label}</Typography>
549
+ </Stack>
550
+ </Box>
551
+ );
552
+ })}
553
+ <DropZone label="Subir imágenes" hint="JPG o PNG" sx={{ minHeight: 0, aspectRatio: "1 / 1.22" }} />
554
+ </Box>
555
+ </Panel>
556
+ </>
557
+ );
558
+ };
559
+
560
+ /* ------------------------------------------------------------------------ */
561
+ /* Video */
562
+ /* ------------------------------------------------------------------------ */
563
+
564
+ const VIDEOS = [
565
+ { name: "Video demostrativo", length: "0:45", state: "ready" },
566
+ { name: "Unboxing", length: "1:20", state: "pending" },
567
+ ];
568
+
569
+ const VideoView = ({ service }) => (
570
+ <>
571
+ <RejectionNote service={service} />
572
+ <Panel title="Videos del producto" aside={<Typography sx={captionSx}>MP4, hasta 500 MB</Typography>}>
573
+ <Box sx={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(220px, 1fr))", gap: 1.5 }}>
574
+ {VIDEOS.map((video) => {
575
+ const state = TILE_STATES[video.state];
576
+ return (
577
+ <Box key={video.name} sx={{ border: `1px solid ${COLORS.s2}`, borderRadius: "8px", overflow: "hidden" }}>
578
+ <Box
579
+ sx={{
580
+ aspectRatio: "16 / 9",
581
+ display: "flex",
582
+ alignItems: "center",
583
+ justifyContent: "center",
584
+ bgcolor: COLORS.deep_gray,
585
+ }}
586
+ >
587
+ <PlayCircleOutlineIcon sx={{ fontSize: 40, color: COLORS.white, opacity: 0.85 }} />
588
+ </Box>
589
+ <Stack direction="row" alignItems="center" spacing={0.75} sx={{ px: 1.25, py: 1 }}>
590
+ <Typography noWrap sx={{ flex: 1, fontFamily: FONT_STACK, fontSize: 13, color: COLORS.color_gray }}>
591
+ {video.name} · {video.length}
592
+ </Typography>
593
+ <Box sx={{ width: 7, height: 7, borderRadius: "50%", bgcolor: state.color }} />
594
+ <Typography sx={{ fontFamily: FONT_STACK, fontSize: 11, color: state.color }}>{state.label}</Typography>
595
+ </Stack>
596
+ </Box>
597
+ );
598
+ })}
599
+ <DropZone label="Subir video" hint="Arrastra el archivo o haz clic" sx={{ aspectRatio: "16 / 11.5" }} />
600
+ </Box>
601
+ </Panel>
602
+ </>
603
+ );
604
+
605
+ /* ------------------------------------------------------------------------ */
606
+ /* Archivos (diseño, 360°, reportes, carga en cadena) */
607
+ /* ------------------------------------------------------------------------ */
608
+
609
+ const FILES = {
610
+ brandstore: [
611
+ ["brandstore_taladro_v2.zip", "Versión 2 · 10/10/2026 · 4.2 MB"],
612
+ ["brandstore_taladro_v1.zip", "Versión 1 · 03/10/2026 · 4.0 MB"],
613
+ ],
614
+ aplusPlatinum: [["aplus_taladro_v1.html", "Versión 1 · 09/10/2026 · 380 KB"]],
615
+ golden: [["golden_thd_taladro_borrador.zip", "Borrador · 12/10/2026 · 2.1 MB"]],
616
+ quality: [["reporte_calidad_11096.pdf", "Versión 1 · 11/10/2026 · 640 KB"]],
617
+ audit: [],
618
+ secodatReview: [["revision_secodat_11096.xlsx", "Versión final · 06/10/2026 · 85 KB"]],
619
+ };
620
+
621
+ const FilesView = ({ service }) => {
622
+ const files = FILES[service.service_name] ?? [];
623
+ return (
624
+ <>
625
+ <RejectionNote service={service} />
626
+ <Panel
627
+ title={`Entregable: ${service.deliverable ?? "Archivo"}`}
628
+ aside={<Typography sx={captionSx}>{files.length} {files.length === 1 ? "archivo" : "archivos"}</Typography>}
629
+ >
630
+ {files.length === 0 ? (
631
+ <Typography sx={{ ...captionSx, mb: 1.5 }}>Aún no hay entregables para este servicio.</Typography>
632
+ ) : (
633
+ <Stack sx={{ mb: 1.5 }}>
634
+ {files.map(([name, meta]) => (
635
+ <Stack
636
+ key={name}
637
+ direction="row"
638
+ alignItems="center"
639
+ spacing={1.5}
640
+ sx={{ py: 1.25, "& + &": { borderTop: `1px solid ${COLORS.s2}` } }}
641
+ >
642
+ <Box
643
+ sx={{
644
+ width: 36,
645
+ height: 36,
646
+ borderRadius: "8px",
647
+ bgcolor: COLORS.s2,
648
+ display: "flex",
649
+ alignItems: "center",
650
+ justifyContent: "center",
651
+ flexShrink: 0,
652
+ }}
653
+ >
654
+ <InsertDriveFileOutlinedIcon sx={{ fontSize: 20, color: COLORS.s4 }} />
655
+ </Box>
656
+ <Box sx={{ flex: 1, minWidth: 0 }}>
657
+ <Typography noWrap sx={{ fontFamily: FONT_STACK, fontSize: 14, color: COLORS.color_gray }}>
658
+ {name}
659
+ </Typography>
660
+ <Typography sx={captionSx}>{meta}</Typography>
661
+ </Box>
662
+ <Button sx={{ ...outlineButtonSx, height: 28, px: 1.5, fontSize: 12 }}>Descargar</Button>
663
+ </Stack>
664
+ ))}
665
+ </Stack>
666
+ )}
667
+ <DropZone label="Subir entregable" hint="PDF, ZIP, HTML o imágenes" />
668
+ </Panel>
669
+ </>
670
+ );
671
+ };
672
+
673
+ /* ------------------------------------------------------------------------ */
674
+
675
+ export const ServiceView = ({ selected, service, services, userId, onSelect, retailerName }) => {
676
+ if (selected === SUMMARY || !service) {
677
+ return <SummaryView services={services} userId={userId} onSelect={onSelect} />;
678
+ }
679
+ switch (service.view) {
680
+ case "attributes":
681
+ return <AttributesView key={service.service_name} service={service} retailerName={retailerName} />;
682
+ case "text":
683
+ return <TextView key={service.service_name} service={service} retailerName={retailerName} />;
684
+ case "translation":
685
+ return (
686
+ <TranslationView
687
+ service={service}
688
+ hasSourceServices={services.some((item) => ["description", "datasheet"].includes(item.service_name))}
689
+ />
690
+ );
691
+ case "images":
692
+ return (
693
+ <ImagesView
694
+ service={service}
695
+ imageServices={services.filter((item) => item.view === "images")}
696
+ onSelect={onSelect}
697
+ userId={userId}
698
+ />
699
+ );
700
+ case "video":
701
+ return <VideoView service={service} />;
702
+ default:
703
+ return <FilesView key={service.service_name} service={service} />;
704
+ }
705
+ };