auth con avatar
Some checks failed
/ Deploy to Cloudflare Pages (push) Has been cancelled

This commit is contained in:
2026-06-18 22:31:22 -03:00
parent 5141a5c06c
commit 4f51c74717
24 changed files with 1122 additions and 47 deletions

View File

@@ -1,4 +1,4 @@
# OBSERVATIONS — Sprint 3
# OBSERVATIONS — Sprint 4
> Hallazgos descubiertos durante el sprint actual.
> Este archivo se vacía al cerrar el sprint: los hallazgos se promocionan a TECH_DEBT.md o BACKLOG.md, o quedan resueltos.
@@ -18,13 +18,23 @@
## Hallazgos activos
*(vacío — sprint recién abierto)*
*(vacío)*
---
## Hallazgos resueltos durante el sprint
*(vacío)*
### [RESOLVED] Web Locks API deadlock en supabase-js v2 — biblioteca colgaba indefinidamente
**Etapa:** 5B (deploy Dokploy)
**Síntoma:** Biblioteca mostraba 0 procesos. Network tab sin llamadas a Supabase después de la primera carga. Import con timeout de 20s. Aparentemente aleatorio — "la primera vez funciona, después no".
**Diagnóstico inicial (asesor):** Race condition entre inicialización de sesión async y fetch de procesos. Correcto en dirección pero incompleto en causa raíz.
**Causa raíz real (Claude Code):** `supabase-js` v2 usa `navigator.locks` (Web Locks API) para coordinar refresh de sesión entre pestañas. Un lock atascado de una pestaña cerrada abruptamente bloqueaba para siempre cualquier llamada futura a `getSession()` o queries. El cliente quedaba en espera indefinida del lock — sin error, sin timeout.
**Fix aplicado:**
- `src/lib/supabase.ts`: cambiar a `processLock` (coordinación en memoria, sin Web Locks API)
- Timeouts con error visible y botón "Reintentar" como red de contención
**Lección para el método:** Síntomas de race condition (funciona la primera vez, no las siguientes) pueden esconder problemas de locks de sistema. Si el network tab no muestra ninguna llamada cuando debería haberlas, sospechar bloqueo a nivel de cliente antes del request, no solo timing de React state.
**Estado:** RESOLVED — Etapas 5 y 5B con OK formal del director (2026-06-18)
---

680
package-lock.json generated
View File

@@ -9,6 +9,7 @@
"version": "0.0.0",
"dependencies": {
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.18",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-progress": "^1.1.8",
"@radix-ui/react-scroll-area": "^1.2.10",
@@ -1961,6 +1962,182 @@
}
}
},
"node_modules/@radix-ui/react-dropdown-menu": {
"version": "2.1.18",
"resolved": "https://registry.npmjs.org/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.18.tgz",
"integrity": "sha512-PZGV82gFk0WltDRI//SsG28ZIjlo9ANTmoNYg0jLNzXXiDsAy5PkOOYQaVD1pPxY6t7gxffb1QMD6qaUvsBZdw==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.4",
"@radix-ui/react-compose-refs": "1.1.3",
"@radix-ui/react-context": "1.1.4",
"@radix-ui/react-id": "1.1.2",
"@radix-ui/react-menu": "2.1.18",
"@radix-ui/react-primitive": "2.1.6",
"@radix-ui/react-use-controllable-state": "1.2.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/primitive": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.4.tgz",
"integrity": "sha512-7AdCK9PQyiljKoBDbN8OuctCbd/esdwZPQ8RtOE3SsyQtUpiPb+ND75q0jEhC1m1ecBI0MFNeLJvwIh9iKHRcQ==",
"license": "MIT"
},
"node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/react-compose-refs": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.3.tgz",
"integrity": "sha512-rYOP8OMnuuPMQF1uhPVlGNcCDlkokKqGFE3JcxFViIkAXP7EvFWUliJAstrapypaBLJNHbZL6jGhbVDGTwmVhA==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/react-context": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.4.tgz",
"integrity": "sha512-QwH4PO5urrbO+FaGd5Aglg+YJgWTyyuZ3g/6mKvsqraLkglDdckw9JafgL5McL5VEJ6EPNduPaT3ZE9BttDAqg==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/react-id": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.2.tgz",
"integrity": "sha512-orBC88futVpqCmhX1p4cvquNHsELQ+w+vBJnuj3ftETI5bJb0bZn3Tqu3SWN2IOcPycTnMGnhwoermvISt72sA==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-use-layout-effect": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/react-primitive": {
"version": "2.1.6",
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.6.tgz",
"integrity": "sha512-wetd0QI77DbvrPpTAvH1SqOxsYF2wZe5TNxqwOd5Ty4XDpV3dpV0s8K/1MGMJBeY5o7lg8ub5VIt1Ub+yVen6g==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-slot": "1.3.0"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/react-slot": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.3.0.tgz",
"integrity": "sha512-MojKku4U/miO8Av4Dkb+ctMAQx7JmY96LmtDQlAarCRtd7rN52QCSzBF+XAvr5S6coSVj9HEPBgHAHKEJVk/WA==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.3"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/react-use-controllable-state": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.3.tgz",
"integrity": "sha512-PLzC90MS+ReootmjC597dvopoelpZ8Q61HJkDXZSExitIq7PL55vHNnesAHwguHK0aPfBnpdNzQtv1uliaqQrA==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-use-effect-event": "0.0.3",
"@radix-ui/react-use-layout-effect": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/react-use-effect-event": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.3.tgz",
"integrity": "sha512-6c8ZqvPTWILEKnyVkP53EGRCcpnJiKTC21sS/6R1GF5xKyHJJWQEPfkqlcgUkdRQivd6tb23abUwe4ngWmY0JA==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-use-layout-effect": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/react-use-layout-effect": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.2.tgz",
"integrity": "sha512-jrBWOxZITuGcnjRCM2t2U5ZPkCLxD+Ym6DjfssS5haTj2iiak/DOb64JeN6OdLfLgptb6/e2kKR+ZuTrGoZTPA==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-focus-guards": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz",
@@ -2065,6 +2242,509 @@
}
}
},
"node_modules/@radix-ui/react-menu": {
"version": "2.1.18",
"resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.18.tgz",
"integrity": "sha512-lj8Rxjtn6zJq1oSbE/uDtAwCbB9BnxgHD+8MwJMuTh6u1dPamYhW9iuELr/Z8d0D/UysFblYYHeBPwi7T4k0YQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.4",
"@radix-ui/react-collection": "1.1.10",
"@radix-ui/react-compose-refs": "1.1.3",
"@radix-ui/react-context": "1.1.4",
"@radix-ui/react-direction": "1.1.2",
"@radix-ui/react-dismissable-layer": "1.1.13",
"@radix-ui/react-focus-guards": "1.1.4",
"@radix-ui/react-focus-scope": "1.1.10",
"@radix-ui/react-id": "1.1.2",
"@radix-ui/react-popper": "1.3.1",
"@radix-ui/react-portal": "1.1.12",
"@radix-ui/react-presence": "1.1.6",
"@radix-ui/react-primitive": "2.1.6",
"@radix-ui/react-roving-focus": "1.1.13",
"@radix-ui/react-slot": "1.3.0",
"@radix-ui/react-use-callback-ref": "1.1.2",
"aria-hidden": "^1.2.4",
"react-remove-scroll": "^2.7.2"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-menu/node_modules/@radix-ui/primitive": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.4.tgz",
"integrity": "sha512-7AdCK9PQyiljKoBDbN8OuctCbd/esdwZPQ8RtOE3SsyQtUpiPb+ND75q0jEhC1m1ecBI0MFNeLJvwIh9iKHRcQ==",
"license": "MIT"
},
"node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-arrow": {
"version": "1.1.10",
"resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.10.tgz",
"integrity": "sha512-j2VTDz1vgCsmuG0k5lBfOcM8n5JPFqZBcMryasFjHYMhwxYL5SRUV5lMSUpRdNtw3D/Sv8pzJtrlAgkssYSsQQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-primitive": "2.1.6"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-collection": {
"version": "1.1.10",
"resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.10.tgz",
"integrity": "sha512-IVVz4EvBcKjrzKgof714qDnz/SzQAkLA2Emh5edlHbgcE6fNd3Un6CJLlaYcnm8N4JmAtzQgse4dOKxcD2yc9g==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.3",
"@radix-ui/react-context": "1.1.4",
"@radix-ui/react-primitive": "2.1.6",
"@radix-ui/react-slot": "1.3.0"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-compose-refs": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.3.tgz",
"integrity": "sha512-rYOP8OMnuuPMQF1uhPVlGNcCDlkokKqGFE3JcxFViIkAXP7EvFWUliJAstrapypaBLJNHbZL6jGhbVDGTwmVhA==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-context": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.4.tgz",
"integrity": "sha512-QwH4PO5urrbO+FaGd5Aglg+YJgWTyyuZ3g/6mKvsqraLkglDdckw9JafgL5McL5VEJ6EPNduPaT3ZE9BttDAqg==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-direction": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.2.tgz",
"integrity": "sha512-C3vFhbyi4SW3PmbAi6Awpu4OzJtd0MxGurvSsYtr7p7nM8RNB3VAF3CUmnp2j50knpkrRcB7+ycVXzgLgF6yNA==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-dismissable-layer": {
"version": "1.1.13",
"resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.13.tgz",
"integrity": "sha512-2v+zNAWWe0ySxgC0D0yeXMPQ23xZVgXZTerTz+JKlmdRj6gfTqmCcR29jb6d290DezXPGgruHWDX/vYUebtErg==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.4",
"@radix-ui/react-compose-refs": "1.1.3",
"@radix-ui/react-primitive": "2.1.6",
"@radix-ui/react-use-callback-ref": "1.1.2",
"@radix-ui/react-use-escape-keydown": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-focus-guards": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.4.tgz",
"integrity": "sha512-cot/aB/mOm0IYVYTTmQcEEK1M48lZWi8FlYe5nDPQQ8NYZUlXEFgncJ9p2Kzer3RKSrY7cTTpEMLZKNo9QoP5Q==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-focus-scope": {
"version": "1.1.10",
"resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.10.tgz",
"integrity": "sha512-Fas/lXQqhVvqwAb64s5RFeHiHYElZ6SUQbZaNd6EkfhP/Al7wTIQ9WIR4QVX475tlu5yFCEdDcJH6/UwsZjMWw==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.3",
"@radix-ui/react-primitive": "2.1.6",
"@radix-ui/react-use-callback-ref": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-id": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.2.tgz",
"integrity": "sha512-orBC88futVpqCmhX1p4cvquNHsELQ+w+vBJnuj3ftETI5bJb0bZn3Tqu3SWN2IOcPycTnMGnhwoermvISt72sA==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-use-layout-effect": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-popper": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.3.1.tgz",
"integrity": "sha512-bhnq/0DEPTi2lsOD3J5rTL65qUKHbKbhqHsmN9TMiclSXpipi651ooUKPPp6G5lF/WiHBdn1s0Wuqsn+myVAvw==",
"license": "MIT",
"dependencies": {
"@floating-ui/react-dom": "^2.0.0",
"@radix-ui/react-arrow": "1.1.10",
"@radix-ui/react-compose-refs": "1.1.3",
"@radix-ui/react-context": "1.1.4",
"@radix-ui/react-primitive": "2.1.6",
"@radix-ui/react-use-callback-ref": "1.1.2",
"@radix-ui/react-use-layout-effect": "1.1.2",
"@radix-ui/react-use-rect": "1.1.2",
"@radix-ui/react-use-size": "1.1.2",
"@radix-ui/rect": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-portal": {
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.12.tgz",
"integrity": "sha512-m309havGzsjLHHaIX50G5PlvRs3xkgPCsGk/5PTvYm8D5q33yG0J7w/712PTOhid7NTaFETtnSXjngHQavvhVw==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-primitive": "2.1.6",
"@radix-ui/react-use-layout-effect": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-presence": {
"version": "1.1.6",
"resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.6.tgz",
"integrity": "sha512-zdTk4PlUO0E18HnZ3wYbW0KkJJxWCdiNYp6g6X1PtONFhxVkg01vliTJAmwIszU6mHiyBOoW9P0rAugl5/hULQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-use-layout-effect": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-primitive": {
"version": "2.1.6",
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.6.tgz",
"integrity": "sha512-wetd0QI77DbvrPpTAvH1SqOxsYF2wZe5TNxqwOd5Ty4XDpV3dpV0s8K/1MGMJBeY5o7lg8ub5VIt1Ub+yVen6g==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-slot": "1.3.0"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-roving-focus": {
"version": "1.1.13",
"resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.13.tgz",
"integrity": "sha512-9gkwneI0guf8JDmrFxPjJF6Ozzgioyw+/lonYNCwefS9ZHA05er0BVHiXr+LbWGHxUfczvMY6G1oiZZi1VzjRw==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.4",
"@radix-ui/react-collection": "1.1.10",
"@radix-ui/react-compose-refs": "1.1.3",
"@radix-ui/react-context": "1.1.4",
"@radix-ui/react-direction": "1.1.2",
"@radix-ui/react-id": "1.1.2",
"@radix-ui/react-primitive": "2.1.6",
"@radix-ui/react-use-callback-ref": "1.1.2",
"@radix-ui/react-use-controllable-state": "1.2.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-slot": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.3.0.tgz",
"integrity": "sha512-MojKku4U/miO8Av4Dkb+ctMAQx7JmY96LmtDQlAarCRtd7rN52QCSzBF+XAvr5S6coSVj9HEPBgHAHKEJVk/WA==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.3"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-use-callback-ref": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.2.tgz",
"integrity": "sha512-xCso9j1/u8sEgP1RNHjFrXJLApL8LiqOkI1R4ywuN00rxWdYg4oQXuwKLS3i0j5NWLromUD27/4nlxj2UFVvIw==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-use-controllable-state": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.3.tgz",
"integrity": "sha512-PLzC90MS+ReootmjC597dvopoelpZ8Q61HJkDXZSExitIq7PL55vHNnesAHwguHK0aPfBnpdNzQtv1uliaqQrA==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-use-effect-event": "0.0.3",
"@radix-ui/react-use-layout-effect": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-use-effect-event": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.3.tgz",
"integrity": "sha512-6c8ZqvPTWILEKnyVkP53EGRCcpnJiKTC21sS/6R1GF5xKyHJJWQEPfkqlcgUkdRQivd6tb23abUwe4ngWmY0JA==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-use-layout-effect": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-use-escape-keydown": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.2.tgz",
"integrity": "sha512-2uVLvLjgO7NZCWw01/FdqRwmA42J0BcjPMUCA+koFEOAb+zjqIP7SiFz/7zWPrKnVmSqr76Omq2ALyCuX4dhLw==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-use-callback-ref": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-use-layout-effect": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.2.tgz",
"integrity": "sha512-jrBWOxZITuGcnjRCM2t2U5ZPkCLxD+Ym6DjfssS5haTj2iiak/DOb64JeN6OdLfLgptb6/e2kKR+ZuTrGoZTPA==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-use-rect": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.2.tgz",
"integrity": "sha512-d8a+bBY/FxikNPlgJJoaBHZX+zKVbWHYJGTLnLvveQgFSTntkGdEKv3JDtHrMS0DNYpllz2nRsTLGLKYttbpmw==",
"license": "MIT",
"dependencies": {
"@radix-ui/rect": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-use-size": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.2.tgz",
"integrity": "sha512-giWQp+4mxjBPt4KZ0MmyuykFNWfbDxKt4x+fPkRYmgRFJSbCZFzUglvMb/Kjn38tm10YP4ufiQZDx3zna4LU6w==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-use-layout-effect": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-menu/node_modules/@radix-ui/rect": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.2.tgz",
"integrity": "sha512-xnXE7wG13PI+cxieVssYXlQJuYVRhH9NBoxt3KNwzghDIA69GMm7d4wXRouHIYjE+KvS6U/MsMO73NdS2MH9ZA==",
"license": "MIT"
},
"node_modules/@radix-ui/react-popper": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.8.tgz",

View File

@@ -14,6 +14,7 @@
},
"dependencies": {
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.18",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-progress": "^1.1.8",
"@radix-ui/react-scroll-area": "^1.2.10",

View File

@@ -0,0 +1,202 @@
# Sprint 4 — Etapa 6: Identidad en UI — header con usuario + "actualizado por"
## Contexto
InQ ROI — React 18 + Vite + TypeScript + TanStack Router + Zustand + shadcn/ui + Tailwind.
Etapas 15B completadas: auth Google, todos los repos en Supabase, RLS granular, UI de visibilidad en biblioteca, fix de Web Locks.
Esta etapa agrega presencia del usuario en la UI:
1. **AppHeader** con avatar, nombre y logout
2. **"Actualizado por [nombre]"** en cada tarjeta de proceso de la biblioteca
No hay cambios en PDF, simulación, workspace ni base de datos.
---
## Estado actual
| Elemento | Estado |
|---|---|
| `AppHeader.tsx` | Estático — solo logo + texto "InQ ROI". No usa `useAuth` |
| Tarjeta de proceso | Tiene timestamp relativo (`updatedAt`) pero sin nombre de persona |
| `updated_by` en `fromRow` | Se escribe en DB pero `fromRow` lo descarta — no llega al tipo `Process` |
| Resolución UUID → nombre | No existe. `ownerId` es UUID crudo, nunca se resuelve a nombre |
---
## Alcance estricto — qué entra en esta etapa
### 1. AppHeader — usuario logueado + logout
Modificar `src/components/AppHeader.tsx` para consumir `useAuth()` y mostrar en el extremo derecho del header:
**Versión mínima:**
- Nombre del usuario (`user.name`) y botón "Cerrar sesión" que llama `authService.signOut()`
**Versión con wow (implementar esta):**
- Avatar circular con la foto del usuario (`user.avatarUrl`) o iniciales si no hay foto, usando el color naranja InQ `#F59845` como fondo de fallback
- Al hacer click en el avatar, despliega un pequeño dropdown con:
- Nombre completo + email del usuario
- Opción "Cerrar sesión" con ícono `LogOut` de Lucide
El dropdown debe cerrarse al hacer click fuera. Usar el componente `DropdownMenu` de shadcn/ui.
**Restricción de marca:** el header usa el gradiente `#F59845 → #ED3E8F` como fondo. El dropdown debe tener fondo blanco/neutro (no gradiente). El texto del nombre en el header (si se muestra fuera del dropdown) va en blanco.
---
### 2. Tipo `Process` — agregar `updatedBy` + resolver nombres en query
#### a) Actualizar `src/domain/types.ts`
Agregar al final de la interfaz `Process`:
```typescript
export interface Process {
// ... campos existentes sin cambios ...
updatedBy: string // 🆕 Sprint 4 — UUID del usuario que hizo el último update
}
```
#### b) Actualizar `src/persistence/supabase/process-repo.ts`
**En `fromRow`:** agregar mapeo del campo:
```typescript
updatedBy: (row.updated_by as string) ?? '',
```
**En `getAll()`:** hacer un JOIN con la tabla `users` para traer nombres en una sola query (evitar N+1):
```typescript
async getAll(): Promise<ProcessWithUserNames[]> {
await supabase.auth.getSession() // barrera de sincronización (fix Etapa 5B)
const { data, error } = await supabase
.from('processes')
.select(`
*,
owner:users!owner_id(id, name, avatar_url),
updater:users!updated_by(id, name)
`)
.order('updated_at', { ascending: false })
if (error) throw error
return (data ?? []).map(fromRowWithUsers)
}
```
#### c) Tipo extendido para display — NO contaminar el dominio
Definir en `src/persistence/supabase/process-repo.ts` (no en `domain/types.ts`):
```typescript
// Solo para display — no es parte del dominio puro
export interface ProcessWithUserNames extends Process {
ownerName: string
ownerAvatarUrl?: string
updaterName: string
}
```
El dominio `Process` mantiene solo `ownerId` y `updatedBy` (UUIDs). La resolución a nombres es responsabilidad del repositorio, no del dominio.
**`fromRowWithUsers`**: función nueva que mapea el resultado del JOIN:
```typescript
function fromRowWithUsers(row: ProcessRowWithJoins): ProcessWithUserNames {
return {
...fromRow(row),
ownerName: (row.owner as { name: string } | null)?.name ?? 'Desconocido',
ownerAvatarUrl: (row.owner as { avatar_url: string } | null)?.avatar_url ?? undefined,
updaterName: (row.updater as { name: string } | null)?.name ?? 'Desconocido',
}
}
```
Si el JOIN falla por alguna razón (usuario eliminado, campo nulo), el fallback es `'Desconocido'` — no crashear.
---
### 3. LibraryPage — "Actualizado por [nombre]" en tarjetas
En `src/features/library/LibraryPage.tsx`:
**Cambiar** el store/hook que provee los procesos para que use `ProcessWithUserNames` en lugar de `Process` — el tipo ya viene resuelto del repositorio.
**En la `ProcessCard`**, reemplazar la línea actual:
```tsx
"Modificado {relativeDate(process.updatedAt)}"
```
Por:
```tsx
"Actualizado por {process.updaterName} · {relativeDate(process.updatedAt)}"
```
**Versión con wow (implementar esta):**
- Si el `updaterName` coincide con el usuario actual (`updaterName === currentUser.name`), mostrar "por vos" en lugar del nombre
- Tooltip al pasar por encima que muestra la fecha completa (no solo relativa)
---
### 4. Workspace — "Actualizado por" en header del proceso (opcional, si cabe en scope)
Si la estructura del workspace ya tiene un lugar para metadatos del proceso (nombre del proceso, cliente), agregar debajo del nombre:
```
Actualizado por [nombre] · hace 2 días
```
Si esto requiere cambios en el store o en cómo WorkspacePage carga el proceso, diferirlo — no es bloqueante para el sprint.
---
## Qué NO entra en esta etapa
- Panel de perfil de usuario (editar nombre, foto)
- Gestión de grupos de usuarios
- ResourcesPanel: UX de catálogo global → Etapa 7
- Cambios en PDF
- Cambios en simulación
- Tests de integración de auth → Etapa 8
---
## Orden de ejecución
1. Actualizar `src/domain/types.ts` — agregar `updatedBy: string` a `Process`
2. Actualizar `src/persistence/supabase/process-repo.ts`:
- `fromRow`: mapear `updated_by`
- Agregar `ProcessWithUserNames` interface
- Agregar `fromRowWithUsers` function
- Modificar `getAll()` para usar JOIN
3. Actualizar el store/hook de biblioteca para propagar `ProcessWithUserNames`
4. Modificar `ProcessCard` en `LibraryPage.tsx` — "Actualizado por [nombre]"
5. Modificar `AppHeader.tsx` — avatar + dropdown con logout
6. (Opcional) "Actualizado por" en workspace si es < 30 min de trabajo
7. `npm run build` — debe pasar sin errores TypeScript
8. `npm run test` — debe mantener 521+ tests verdes
9. Commit: `feat(sprint-4/etapa-6): identidad en UI — header con usuario + actualizado por`
---
## Criterios de validación
- [ ] AppHeader muestra avatar del usuario logueado (o iniciales con fondo naranja si no hay foto)
- [ ] Click en avatar despliega dropdown con nombre, email y "Cerrar sesión"
- [ ] "Cerrar sesión" funciona — redirige a `/login`
- [ ] Tarjetas de proceso muestran "Actualizado por [nombre]"
- [ ] Si el updater es el usuario actual, muestra "por vos"
- [ ] El JOIN en `getAll()` no rompe el build (TypeScript sin `any` injustificado)
- [ ] `Process.updatedBy` existe en `types.ts`
- [ ] `fromRow` mapea `updated_by`
- [ ] `npm run build` limpio
- [ ] 521+ tests verdes
- [ ] **Validación visual del director** — ver avatar en header y nombres en tarjetas antes del OK formal
---
## Nota sobre el JOIN de Supabase
El JOIN `users!owner_id(...)` usa la sintaxis de PostgREST para FK explícita. Si la FK no está declarada en el schema de Supabase (aunque exista en PostgreSQL), el join puede fallar. Verificar en Supabase Dashboard → Table Editor → processes que las FK `owner_id → users.id` y `updated_by → users.id` aparecen como relaciones. Si no aparecen, agregar al schema o usar un select con `.eq()` fallback.
Alternativa si el JOIN falla: hacer `getAll()` + un `supabase.from('users').select('id, name, avatar_url')` separado y mapear en memoria. Menos elegante pero más robusto. Preferir el JOIN pero tener este fallback documentado.

View File

@@ -1,6 +1,44 @@
import { Link } from '@tanstack/react-router'
import { LogOut } from 'lucide-react'
import { useAuth } from '@/auth/AuthContext'
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
} from '@/components/ui/dropdown-menu'
function initials(name: string): string {
const parts = name.trim().split(/\s+/)
if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase()
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase()
}
function UserAvatar({ name, avatarUrl }: { name: string; avatarUrl?: string }) {
if (avatarUrl) {
return (
<img
src={avatarUrl}
alt={name}
className="h-7 w-7 rounded-full object-cover border border-white/40"
/>
)
}
return (
<div
className="h-7 w-7 rounded-full flex items-center justify-center text-[11px] font-medium text-white border border-white/40"
style={{ background: '#F59845' }}
>
{initials(name)}
</div>
)
}
export function AppHeader() {
const { user, signOut } = useAuth()
return (
<header
style={{
@@ -21,19 +59,41 @@ export function AppHeader() {
style={{ height: 24, objectFit: 'contain', cursor: 'pointer' }}
/>
</Link>
<Link
to="/"
style={{
color: 'white',
fontSize: 13,
fontWeight: 600,
letterSpacing: '0.04em',
opacity: 0.92,
textDecoration: 'none',
}}
>
InQ ROI
</Link>
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
<Link
to="/"
style={{
color: 'white',
fontSize: 13,
fontWeight: 600,
letterSpacing: '0.04em',
opacity: 0.92,
textDecoration: 'none',
}}
>
InQ ROI
</Link>
{user && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button className="rounded-full focus:outline-none focus:ring-2 focus:ring-white/60" title={user.name}>
<UserAvatar name={user.name} avatarUrl={user.avatarUrl} />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>
<p className="text-sm font-medium leading-tight">{user.name}</p>
<p className="text-xs text-muted-foreground leading-tight mt-0.5">{user.email}</p>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => signOut()} className="text-destructive focus:text-destructive">
<LogOut className="h-3.5 w-3.5" />
Cerrar sesión
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
</header>
)
}

View File

@@ -0,0 +1,76 @@
import * as React from 'react'
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu'
import { cn } from '@/lib/utils'
const DropdownMenu = DropdownMenuPrimitive.Root
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
const DropdownMenuGroup = DropdownMenuPrimitive.Group
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
'z-50 min-w-[200px] overflow-hidden rounded-md border border-border bg-popover p-1 text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
))
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
'relative flex cursor-pointer select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-secondary focus:text-secondary-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className
)}
{...props}
/>
))
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn('px-2 py-1.5 text-sm font-medium', className)}
{...props}
/>
))
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn('-mx-1 my-1 h-px bg-border', className)}
{...props}
/>
))
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuGroup,
DropdownMenuPortal,
}

View File

@@ -19,6 +19,7 @@ export interface Process {
groupId: UUID | null // 🆕 Sprint 3 — null = proceso sin grupo asignado ("Sin clasificar")
tags: string[] // 🆕 Sprint 3 — tags libres para clasificación y búsqueda
ownerId: string // 🆕 Sprint 4 — owner del proceso (auth.uid() al crear)
updatedBy: string // 🆕 Sprint 4 — UUID del usuario que hizo el último update
}
export type ActivityType = 'task' | 'subprocess'

View File

@@ -81,6 +81,7 @@ export function ImportPage() {
groupId: targetGroupId,
tags: [],
ownerId: user!.id,
updatedBy: user!.id,
}
const activityElements = extractActivityElements(graph)

View File

@@ -7,8 +7,8 @@ import {
} from 'lucide-react'
import { useLibraryStore } from '@/store/library-store'
import { useAuth } from '@/auth/AuthContext'
import { supabaseProcessRepo } from '@/persistence/supabase/process-repo'
import { formatCurrency } from '@/lib/format'
import { supabaseProcessRepo, type ProcessWithUserNames } from '@/persistence/supabase/process-repo'
import { formatCurrency, formatSimulationTimestamp } from '@/lib/format'
import type { Process, ProcessGroup, Simulation } from '@/domain/types'
import { AppHeader } from '@/components/AppHeader'
@@ -123,18 +123,21 @@ function KpiBadge({ process, simulation }: { process: Process; simulation: Simul
// ─── Process Card ─────────────────────────────────────────────────────────────
function ProcessCard({ process, simulation, showGroupChip, groupName, onDelete, currentUserId, onShared }: {
process: Process
function ProcessCard({ process, simulation, showGroupChip, groupName, onDelete, currentUserId, currentUserName, onShared }: {
process: ProcessWithUserNames
simulation: Simulation | null | undefined
showGroupChip?: boolean
groupName?: string
onDelete?: () => void
currentUserId: string
currentUserName: string
onShared?: () => void
}) {
const navigate = useNavigate()
const [menuState, setMenuState] = useState<null | 'menu' | 'confirm'>(null)
const isOwn = process.ownerId === currentUserId
const updatedByMe = process.updaterName === currentUserName
const fullDate = formatSimulationTimestamp(process.updatedAt)
// Cierra el menú al hacer click fuera de él
useEffect(() => {
@@ -164,9 +167,11 @@ function ProcessCard({ process, simulation, showGroupChip, groupName, onDelete,
<p className="text-[13px] font-medium truncate">{process.name || 'Sin nombre'}</p>
<OwnershipBadge process={process} currentUserId={currentUserId} />
</div>
<div className="flex items-center gap-1 mt-0.5">
<div className="flex items-center gap-1 mt-0.5" title={`${fullDate.date} a las ${fullDate.time}`}>
<Clock className="h-3 w-3 text-muted-foreground" />
<span className="text-[11px] text-muted-foreground">Modificado {relativeDate(process.updatedAt)}</span>
<span className="text-[11px] text-muted-foreground">
Actualizado por {updatedByMe ? 'vos' : process.updaterName} · {relativeDate(process.updatedAt)}
</span>
</div>
{(process.tags.length > 0 || showGroupChip) && (
<div className="flex flex-wrap gap-1 mt-1">
@@ -373,12 +378,14 @@ function HomeView({
onImport,
ownerFilter,
currentUserId,
currentUserName,
onShared,
}: {
onNavigateToGroup: (groupId: string | null) => void
onImport: (groupId?: string) => void
ownerFilter: 'all' | 'mine'
currentUserId: string
currentUserName: string
onShared: () => void
}) {
const { groups, processes, settings, createGroup, isLoading, error, load } = useLibraryStore()
@@ -512,6 +519,7 @@ function HomeView({
showGroupChip
groupName={group?.name}
currentUserId={currentUserId}
currentUserName={currentUserName}
onShared={onShared}
/>
)
@@ -614,11 +622,12 @@ function HomeView({
// ─── ProcessCardWithSim (carga simulación lazy) ───────────────────────────────
function ProcessCardWithSim({ process, showGroupChip, groupName, currentUserId, onShared }: {
process: Process
function ProcessCardWithSim({ process, showGroupChip, groupName, currentUserId, currentUserName, onShared }: {
process: ProcessWithUserNames
showGroupChip?: boolean
groupName?: string
currentUserId: string
currentUserName: string
onShared: () => void
}) {
const { getLatestSimulation, deleteProcess } = useLibraryStore()
@@ -640,6 +649,7 @@ function ProcessCardWithSim({ process, showGroupChip, groupName, currentUserId,
groupName={groupName}
onDelete={() => deleteProcess(process.id)}
currentUserId={currentUserId}
currentUserName={currentUserName}
onShared={onShared}
/>
)
@@ -651,12 +661,14 @@ function GroupView({
groupId,
onBack,
currentUserId,
currentUserName,
ownerFilter,
onShared,
}: {
groupId: string | null
onBack: () => void
currentUserId: string
currentUserName: string
ownerFilter: 'all' | 'mine'
onShared: () => void
}) {
@@ -721,6 +733,7 @@ function GroupView({
key={p.id}
process={p}
currentUserId={currentUserId}
currentUserName={currentUserName}
onShared={onShared}
/>
))}
@@ -773,6 +786,7 @@ export function LibraryPage() {
}
const currentUserId = user!.id
const currentUserName = user!.name
const totalGroups = groups.length
return (
@@ -841,6 +855,7 @@ export function LibraryPage() {
onImport={handleImport}
ownerFilter={ownerFilter}
currentUserId={currentUserId}
currentUserName={currentUserName}
onShared={() => showToast('Proceso compartido con el equipo')}
/>
) : (
@@ -848,6 +863,7 @@ export function LibraryPage() {
groupId={activeGroupId}
onBack={navigateHome}
currentUserId={currentUserId}
currentUserName={currentUserName}
ownerFilter={ownerFilter}
onShared={() => showToast('Proceso compartido con el equipo')}
/>

View File

@@ -38,6 +38,31 @@ function fromRow(row: Record<string, unknown>): Process {
createdAt: new Date(row.created_at as string).getTime(),
updatedAt: new Date(row.updated_at as string).getTime(),
ownerId: (row.owner_id as string) ?? '',
updatedBy: (row.updated_by as string) ?? '',
}
}
// Solo para display — no es parte del dominio puro. La resolución UUID → nombre
// es responsabilidad del repositorio, no del dominio (que solo conoce ownerId/updatedBy).
export interface ProcessWithUserNames extends Process {
ownerName: string
ownerAvatarUrl?: string
updaterName: string
}
interface UserRef {
name: string | null
avatar_url?: string | null
}
function fromRowWithUsers(row: Record<string, unknown>): ProcessWithUserNames {
const owner = row.owner as UserRef | null
const updater = row.updater as UserRef | null
return {
...fromRow(row),
ownerName: owner?.name ?? 'Desconocido',
ownerAvatarUrl: owner?.avatar_url ?? undefined,
updaterName: updater?.name ?? 'Desconocido',
}
}
@@ -57,13 +82,16 @@ export const supabaseProcessRepo = {
return fromRow(data)
},
async getAll(): Promise<Process[]> {
// JOIN con users para resolver owner/updater en una sola query (evita N+1).
// Si las FK owner_id/updated_by no están declaradas como relaciones en PostgREST,
// este select falla — en ese caso usar el fallback de dos queries + mapeo en memoria.
async getAll(): Promise<ProcessWithUserNames[]> {
const { data, error } = await supabase
.from('processes')
.select('*')
.select('*, owner:users!owner_id(name, avatar_url), updater:users!updated_by(name)')
.order('updated_at', { ascending: false })
if (error) throw error
return (data ?? []).map(fromRow)
return (data ?? []).map((row) => fromRowWithUsers(row as Record<string, unknown>))
},
async delete(id: string): Promise<void> {

View File

@@ -1,15 +1,15 @@
import { create } from 'zustand'
import { v4 as uuidv4 } from 'uuid'
import { getSessionSafe } from '@/lib/supabase'
import { supabaseProcessRepo } from '@/persistence/supabase/process-repo'
import { supabaseProcessRepo, type ProcessWithUserNames } from '@/persistence/supabase/process-repo'
import { supabaseGroupRepo } from '@/persistence/supabase/group-repo'
import { supabaseSettingsRepo } from '@/persistence/supabase/settings-repo'
import { supabaseSimulationRepo } from '@/persistence/supabase/simulation-repo'
import type { Process, ProcessGroup, AppSettings, Simulation } from '@/domain/types'
import type { ProcessGroup, AppSettings, Simulation } from '@/domain/types'
interface LibraryState {
groups: ProcessGroup[]
processes: Process[]
processes: ProcessWithUserNames[]
settings: AppSettings
isLoading: boolean
error: string | null
@@ -23,7 +23,7 @@ interface LibraryState {
assignProcessToGroup: (processId: string, groupId: string | null, userId: string) => Promise<void>
getProcessesByGroup: (groupId: string | null) => Process[]
getProcessesByGroup: (groupId: string | null) => ProcessWithUserNames[]
getLatestSimulation: (processId: string) => Promise<Simulation | undefined>
updateSettings: (updates: Partial<Omit<AppSettings, 'id'>>) => Promise<void>

View File

@@ -229,7 +229,7 @@ describe('MethodologyFooter', () => {
id: 'p1', name: 'Proceso Test', clientName: '',
bpmnXml: '<def/>', currency: 'USD',
overheadPercentage: 0.2, annualFrequency: 1000, analysisHorizonYears: 3, automationInvestment: 0,
createdAt: 0, updatedAt: 0, groupId: null, tags: [], ownerId: 'test-user',
createdAt: 0, updatedAt: 0, groupId: null, tags: [], ownerId: 'test-user', updatedBy: 'test-user',
}
expect(() => render(<MethodologyFooter process={proc} />)).not.toThrow()
})
@@ -239,7 +239,7 @@ describe('MethodologyFooter', () => {
id: 'p1', name: 'Proc', clientName: '',
bpmnXml: '', currency: 'USD',
overheadPercentage: 0.25, annualFrequency: 1000, analysisHorizonYears: 3, automationInvestment: 0,
createdAt: 0, updatedAt: 0, groupId: null, tags: [], ownerId: 'test-user',
createdAt: 0, updatedAt: 0, groupId: null, tags: [], ownerId: 'test-user', updatedBy: 'test-user',
}
render(<MethodologyFooter process={proc} />)
const text = document.body.textContent ?? ''
@@ -251,7 +251,7 @@ describe('MethodologyFooter', () => {
id: 'p1', name: 'Proc', clientName: '',
bpmnXml: '', currency: 'USD',
overheadPercentage: 0.2, annualFrequency: 1000, analysisHorizonYears: 3, automationInvestment: 0,
createdAt: 0, updatedAt: 0, groupId: null, tags: [], ownerId: 'test-user',
createdAt: 0, updatedAt: 0, groupId: null, tags: [], ownerId: 'test-user', updatedBy: 'test-user',
}
render(<MethodologyFooter process={proc} />)
const text = document.body.textContent ?? ''
@@ -338,7 +338,7 @@ describe('ReportPage — con datos completos', () => {
updatedAt: Date.now() - 1000 * 60 * 30,
groupId: null,
tags: [],
ownerId: 'test-user',
ownerId: 'test-user', updatedBy: 'test-user',
}
const mockSimulation = {

View File

@@ -590,7 +590,7 @@ const mockProcessBase = {
currency: 'USD', overheadPercentage: 0.2,
annualFrequency: 1200, analysisHorizonYears: 3, automationInvestment: 50_000,
createdAt: Date.now() - 3600_000, updatedAt: Date.now() - 3600_000,
groupId: null, tags: [], ownerId: 'test-user',
groupId: null, tags: [], ownerId: 'test-user', updatedBy: 'test-user',
}
const mockSimulationBase = {

View File

@@ -14,7 +14,7 @@ function makeProcess(extras: Partial<Process> = {}): Process {
id: 'p1', name: 'Proceso de Crédito', clientName: 'Banco XYZ',
bpmnXml: '', currency: 'USD', overheadPercentage: 0.2,
annualFrequency: 1200, analysisHorizonYears: 3, automationInvestment: 50_000,
createdAt: 0, updatedAt: 0, groupId: null, tags: [], ownerId: 'test-user', ...extras,
createdAt: 0, updatedAt: 0, groupId: null, tags: [], ownerId: 'test-user', updatedBy: 'test-user', ...extras,
}
}

View File

@@ -9,7 +9,7 @@ function makeProcess(currency: string, extras: Partial<Process> = {}): Process {
id: 'p1', name: 'Proceso de Ventas', clientName: 'Empresa ABC',
bpmnXml: '', currency, overheadPercentage: 0.2,
annualFrequency: 1000, analysisHorizonYears: 3, automationInvestment: 0,
createdAt: 0, updatedAt: 0, groupId: null, tags: [], ownerId: 'test-user', ...extras,
createdAt: 0, updatedAt: 0, groupId: null, tags: [], ownerId: 'test-user', updatedBy: 'test-user', ...extras,
}
}

View File

@@ -66,7 +66,7 @@ describe('Medición de tamaños de CSV — 3 sample BPMNs', () => {
bpmnXml: xml, currency: 'USD', overheadPercentage: 0.2,
annualFrequency: 1000, analysisHorizonYears: 3, automationInvestment: 0,
createdAt: 0, updatedAt: 0,
groupId: null, tags: [], ownerId: 'test-user',
groupId: null, tags: [], ownerId: 'test-user', updatedBy: 'test-user',
}
const simulation: Simulation = {

View File

@@ -51,7 +51,7 @@ const mockProcess: Process = {
bpmnXml: '', currency: 'USD', overheadPercentage: 0.2,
annualFrequency: 1200, analysisHorizonYears: 3, automationInvestment: 50_000,
createdAt: 0, updatedAt: 0,
groupId: null, tags: [], ownerId: 'test-user',
groupId: null, tags: [], ownerId: 'test-user', updatedBy: 'test-user',
}
const perActivityActual = [

View File

@@ -43,7 +43,7 @@ const mockProcess: Process = {
bpmnXml: '', currency: 'USD', overheadPercentage: 0.2,
annualFrequency: 1000, analysisHorizonYears: 3, automationInvestment: 0,
createdAt: 0, updatedAt: 0,
groupId: null, tags: [], ownerId: 'test-user',
groupId: null, tags: [], ownerId: 'test-user', updatedBy: 'test-user',
}
// Simulación SIN recursos en ninguna actividad

View File

@@ -54,7 +54,7 @@ const mockProcess: Process = {
overheadPercentage: 0.2,
annualFrequency: 1000, analysisHorizonYears: 3, automationInvestment: 0,
createdAt: 0, updatedAt: 0,
groupId: null, tags: [], ownerId: 'test-user',
groupId: null, tags: [], ownerId: 'test-user', updatedBy: 'test-user',
}
const mockSimulation: Simulation = {

View File

@@ -59,7 +59,7 @@ const mockProcess: Process = {
overheadPercentage: 0.2,
annualFrequency: 1000, analysisHorizonYears: 3, automationInvestment: 50000,
createdAt: 0, updatedAt: 0,
groupId: null, tags: [], ownerId: 'test-user',
groupId: null, tags: [], ownerId: 'test-user', updatedBy: 'test-user',
}
const mockSimulation: Simulation = {

View File

@@ -56,7 +56,7 @@ const mockProcess: Process = {
overheadPercentage: 0.2,
annualFrequency: 5000, analysisHorizonYears: 3, automationInvestment: 80000,
createdAt: 0, updatedAt: 0,
groupId: null, tags: [], ownerId: 'test-user',
groupId: null, tags: [], ownerId: 'test-user', updatedBy: 'test-user',
}
const mockSimulation: Simulation = {

View File

@@ -52,7 +52,7 @@ const mockProcess: Process = {
bpmnXml: '', currency: 'USD', overheadPercentage: 0.2,
annualFrequency: 5000, analysisHorizonYears: 3, automationInvestment: 80000,
createdAt: 0, updatedAt: 0,
groupId: null, tags: [], ownerId: 'test-user',
groupId: null, tags: [], ownerId: 'test-user', updatedBy: 'test-user',
}
const mockRoi: RoiResult = {

View File

@@ -52,7 +52,7 @@ const mockProcess: Process = {
bpmnXml: '', currency: 'USD', overheadPercentage: 0.2,
annualFrequency: 5000, analysisHorizonYears: 3, automationInvestment: 80000,
createdAt: 0, updatedAt: 0,
groupId: null, tags: [], ownerId: 'test-user',
groupId: null, tags: [], ownerId: 'test-user', updatedBy: 'test-user',
}
const mockSimulation: Simulation = {

View File

@@ -94,7 +94,7 @@ describe('simulation store — invalidación por cambio en process store', () =>
currency: 'USD', overheadPercentage: 0.2,
annualFrequency: 500, analysisHorizonYears: 2, automationInvestment: 10_000,
createdAt: Date.now(), updatedAt: Date.now(),
groupId: null, tags: [], ownerId: 'test-user',
groupId: null, tags: [], ownerId: 'test-user', updatedBy: 'test-user',
}
useProcessStore.setState({ currentProcess: proc })