Feature A — Procesos por recurso (/recursos)
Some checks failed
/ Deploy to Cloudflare Pages (push) Has been cancelled
Some checks failed
/ Deploy to Cloudflare Pages (push) Has been cancelled
This commit is contained in:
@@ -29,6 +29,12 @@
|
|||||||
|
|
||||||
## Hallazgos resueltos durante el sprint
|
## Hallazgos resueltos durante el sprint
|
||||||
|
|
||||||
|
### [RESOLVED] Etapas 3 + 3B — Heatmap gradiente + toggle + persistencia
|
||||||
|
|
||||||
|
**Estado:** OK formal del director — 2026-06-19.
|
||||||
|
**Entregado:** gradiente 4 stops (#10b981→#f59e0b→#f97316→#ef4444), modos percentile/minmax, `useHeatmapMode` hook con localStorage, HeatmapModeToggle, leyenda PDF 4 stops, zoom controls movidos a bottom-left.
|
||||||
|
**Lección operativa:** Dokploy con Autodeploy desactivado requiere `git push origin main` explícito antes de validar en producción. Los commits `a65bec4` y `e8a4a1b` estaban locales sin pushear — Dokploy desplegaba Etapa 2 mientras se validaba Etapa 3. La validación fallida fue contra código desactualizado, no contra bugs reales.
|
||||||
|
|
||||||
### [RESOLVED] Etapa 2 — Zoom BPMN fix + controles + skeleton
|
### [RESOLVED] Etapa 2 — Zoom BPMN fix + controles + skeleton
|
||||||
|
|
||||||
**Estado:** OK formal del director — 2026-06-19.
|
**Estado:** OK formal del director — 2026-06-19.
|
||||||
|
|||||||
273
sprints/sprint-5/ETAPA_4_PROMPT.md
Normal file
273
sprints/sprint-5/ETAPA_4_PROMPT.md
Normal file
@@ -0,0 +1,273 @@
|
|||||||
|
# Sprint 5 — Etapa 4: Procesos por recurso + campo empresa en perfil
|
||||||
|
|
||||||
|
## Contexto
|
||||||
|
|
||||||
|
InQ ROI — React 18 + Vite + TypeScript + TanStack Router + Zustand + shadcn/ui + Tailwind + Supabase.
|
||||||
|
Etapas 1, 2, 3 y 3B completadas con OK formal. Esta etapa agrega dos features pequeñas de producto.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Feature A — Ver procesos asociados a un recurso
|
||||||
|
|
||||||
|
### Contexto actual
|
||||||
|
|
||||||
|
`ResourceCatalogPage` tiene 5 columnas: Recurso, Tipo, Costo/h, Creado por, Acciones. El botón Eliminar ya protege contra borrado de recursos en uso (verifica `activity_resource_assignments`), pero el usuario no puede ver qué procesos usan el recurso antes de intentar eliminarlo.
|
||||||
|
|
||||||
|
### Implementación
|
||||||
|
|
||||||
|
**Paso 1 — Nueva query en `resource-repo.ts`:**
|
||||||
|
|
||||||
|
Agregar un método `getProcessCountsPerResource()` que devuelva un map de `resourceId → cantidad de procesos distintos`. Implementar con una sola query (no una por recurso) para evitar N+1:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
async getProcessCountsPerResource(): Promise<Record<string, number>> {
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('activity_resource_assignments')
|
||||||
|
.select('resource_id, activity:activities!inner(process_id)')
|
||||||
|
|
||||||
|
if (error) throw error
|
||||||
|
if (!data) return {}
|
||||||
|
|
||||||
|
// Agregar client-side: contar procesos DISTINTOS por recurso
|
||||||
|
const processSets: Record<string, Set<string>> = {}
|
||||||
|
for (const row of data) {
|
||||||
|
const resourceId = row.resource_id
|
||||||
|
const processId = (row.activity as { process_id: string }).process_id
|
||||||
|
if (!processSets[resourceId]) processSets[resourceId] = new Set()
|
||||||
|
processSets[resourceId].add(processId)
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.fromEntries(
|
||||||
|
Object.entries(processSets).map(([k, v]) => [k, v.size])
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Paso 2 — Cargar los conteos en `ResourceCatalogPage`:**
|
||||||
|
|
||||||
|
Al cargar los recursos (`useEffect` de mount), también llamar a `getProcessCountsPerResource()` en paralelo:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const [resources, processCounts] = await Promise.all([
|
||||||
|
supabaseResourceRepo.getAllWithCreator(),
|
||||||
|
supabaseResourceRepo.getProcessCountsPerResource(),
|
||||||
|
])
|
||||||
|
```
|
||||||
|
|
||||||
|
Guardar `processCounts` en estado local del componente.
|
||||||
|
|
||||||
|
**Paso 3 — Nueva columna "Procesos" en la tabla:**
|
||||||
|
|
||||||
|
Agregar columna entre "Creado por" y "Acciones":
|
||||||
|
|
||||||
|
- Header: "Procesos"
|
||||||
|
- Si `processCounts[resource.id] === 0` o undefined: mostrar "—" en color muted
|
||||||
|
- Si `processCounts[resource.id] > 0`: mostrar badge clicable con el número
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
{count > 0 ? (
|
||||||
|
<button
|
||||||
|
onClick={() => toggleExpanded(resource.id)}
|
||||||
|
className="inline-flex items-center gap-1 text-xs font-medium text-orange-600 bg-orange-50 hover:bg-orange-100 rounded-md px-2 py-0.5 transition-colors"
|
||||||
|
>
|
||||||
|
{count} {count === 1 ? 'proceso' : 'procesos'}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground">—</span>
|
||||||
|
)}
|
||||||
|
```
|
||||||
|
|
||||||
|
El color naranja InQ (`text-orange-600`, `bg-orange-50`) es correcto para indicar "hay uso activo".
|
||||||
|
|
||||||
|
**Paso 4 — Expansión inline con lista de procesos:**
|
||||||
|
|
||||||
|
Al hacer click en el badge, expandir una fila adicional debajo con los nombres de los procesos.
|
||||||
|
|
||||||
|
Para esto necesitás una query adicional que devuelva los nombres — la query anterior solo devuelve conteos. Agregar un segundo método en `resource-repo.ts`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
async getProcessesForResource(resourceId: string): Promise<{ id: string; name: string }[]> {
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('activity_resource_assignments')
|
||||||
|
.select('activity:activities!inner(process:processes!inner(id, name))')
|
||||||
|
.eq('resource_id', resourceId)
|
||||||
|
|
||||||
|
if (error) throw error
|
||||||
|
if (!data) return []
|
||||||
|
|
||||||
|
// Deduplicar por proceso id
|
||||||
|
const seen = new Set<string>()
|
||||||
|
const result: { id: string; name: string }[] = []
|
||||||
|
for (const row of data) {
|
||||||
|
const process = (row.activity as any).process
|
||||||
|
if (!seen.has(process.id)) {
|
||||||
|
seen.add(process.id)
|
||||||
|
result.push({ id: process.id, name: process.name })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Esta query se llama **on demand** (solo cuando el usuario expande la fila), no al cargar la página.
|
||||||
|
|
||||||
|
La fila expandida muestra:
|
||||||
|
- Lista de nombres de proceso, cada uno como link a `/workspace/:id`
|
||||||
|
- Usar `<Link>` de TanStack Router
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Fila expandida (colspan completo de la tabla):
|
||||||
|
<tr>
|
||||||
|
<td colSpan={6} className="px-4 pb-3 pt-0">
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{processes.map(p => (
|
||||||
|
<Link key={p.id} to="/workspace/$processId" params={{ processId: p.id }}
|
||||||
|
className="text-xs text-blue-600 hover:underline">
|
||||||
|
{p.name}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Feature B — Campo empresa en perfil de usuario
|
||||||
|
|
||||||
|
### Contexto actual
|
||||||
|
|
||||||
|
`AuthUser` en `/src/auth/types.ts` tiene: `id, email, name, avatarUrl?, platformRole`. Sin `company`.
|
||||||
|
|
||||||
|
`buildAuthUser()` construye el objeto desde OAuth `user_metadata` de Google. No lee la tabla `users` de Supabase directamente.
|
||||||
|
|
||||||
|
El AppHeader dropdown muestra: avatar + nombre + email + botón "Cerrar sesión".
|
||||||
|
|
||||||
|
### Implementación
|
||||||
|
|
||||||
|
**Paso 1 — Migración en Supabase:**
|
||||||
|
|
||||||
|
Crear archivo de migración `supabase/migrations/006_add_company_to_users.sql`:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
ALTER TABLE users ADD COLUMN IF NOT EXISTS company text;
|
||||||
|
```
|
||||||
|
|
||||||
|
Ejecutar en Supabase Studio SQL Editor. Verificar que no rompe datos existentes (la columna es nullable sin default).
|
||||||
|
|
||||||
|
**Paso 2 — Extender `AuthUser`:**
|
||||||
|
|
||||||
|
En `/src/auth/types.ts`:
|
||||||
|
```ts
|
||||||
|
export interface AuthUser {
|
||||||
|
id: string
|
||||||
|
email: string
|
||||||
|
name: string
|
||||||
|
avatarUrl?: string
|
||||||
|
platformRole: 'platform_admin' | 'member' | 'guest'
|
||||||
|
company?: string // 🆕 nullable, viene de tabla users
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Paso 3 — Leer `company` desde la tabla `users`:**
|
||||||
|
|
||||||
|
`buildAuthUser()` actualmente solo usa `user_metadata` de OAuth. Para `company` necesitamos leer la tabla `users` de Supabase (que ya existe y se populó en Sprint 4).
|
||||||
|
|
||||||
|
La forma más limpia: después de que el usuario autentique, hacer una query puntual a `users` para obtener el `company`. Este fetch puede ir en el `onAuthStateChange` callback, justo después de construir el `AuthUser` base:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// En AuthContext o SupabaseAuthService, después de obtener el usuario de OAuth:
|
||||||
|
const { data: userRow } = await supabase
|
||||||
|
.from('users')
|
||||||
|
.select('company, name')
|
||||||
|
.eq('id', user.id)
|
||||||
|
.maybeSingle()
|
||||||
|
|
||||||
|
const authUser: AuthUser = {
|
||||||
|
...buildAuthUser(session.user),
|
||||||
|
company: userRow?.company ?? undefined,
|
||||||
|
// Si el nombre fue editado en la app (no solo el de Google), usar el de la tabla:
|
||||||
|
name: userRow?.name ?? buildAuthUser(session.user).name,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Paso 4 — UI en AppHeader: mostrar empresa**
|
||||||
|
|
||||||
|
En el dropdown del AppHeader, agregar la empresa debajo del email si está definida:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<div className="flex flex-col px-3 py-2 text-sm">
|
||||||
|
<span className="font-medium">{user.name}</span>
|
||||||
|
<span className="text-muted-foreground text-xs">{user.email}</span>
|
||||||
|
{user.company && (
|
||||||
|
<span className="text-muted-foreground text-xs mt-0.5">{user.company}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Paso 5 — Edición de perfil:**
|
||||||
|
|
||||||
|
Agregar un item "Editar perfil" en el dropdown (antes de "Cerrar sesión"), que abre un `Sheet` (el mismo componente Sheet de shadcn/ui ya usado en `ResourceFormSheet`):
|
||||||
|
|
||||||
|
El Sheet de perfil tiene:
|
||||||
|
- **Nombre** — input editable (texto libre)
|
||||||
|
- **Empresa** — input editable (texto libre, placeholder "InQuality")
|
||||||
|
- **Email** — solo lectura (viene de Google OAuth, no editable)
|
||||||
|
- Botón "Guardar" — hace `UPDATE users SET name = ?, company = ? WHERE id = ?`
|
||||||
|
- Botón "Cancelar"
|
||||||
|
|
||||||
|
Al guardar: actualizar el store de auth local para que el AppHeader refleje el cambio sin recargar.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// En el repo o en el store, al guardar perfil:
|
||||||
|
await supabase
|
||||||
|
.from('users')
|
||||||
|
.update({ name, company })
|
||||||
|
.eq('id', user.id)
|
||||||
|
|
||||||
|
// Actualizar el AuthUser en el store local:
|
||||||
|
updateUser({ name, company })
|
||||||
|
```
|
||||||
|
|
||||||
|
`updateUser` debe existir en `AuthContext` o `useAuth` — si no existe, crearlo.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Qué NO entra en esta etapa
|
||||||
|
|
||||||
|
- Cambios en la lógica de simulación o heatmap
|
||||||
|
- Gestión de roles o permisos de usuario
|
||||||
|
- Página de perfil separada (el Sheet del dropdown es suficiente)
|
||||||
|
- Campos adicionales más allá de nombre y empresa
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Orden de ejecución
|
||||||
|
|
||||||
|
1. Migración SQL `006_add_company_to_users.sql` — ejecutar en Supabase + crear archivo
|
||||||
|
2. Extender `AuthUser` con `company?` en `src/auth/types.ts`
|
||||||
|
3. Modificar `buildAuthUser` / `onAuthStateChange` para leer `company` y `name` de tabla `users`
|
||||||
|
4. AppHeader: mostrar empresa en dropdown si existe
|
||||||
|
5. AppHeader: agregar "Editar perfil" → Sheet con nombre/empresa editables
|
||||||
|
6. `resource-repo.ts`: agregar `getProcessCountsPerResource()` y `getProcessesForResource()`
|
||||||
|
7. `ResourceCatalogPage`: cargar conteos en paralelo con recursos, agregar columna "Procesos"
|
||||||
|
8. `ResourceCatalogPage`: expansión inline de fila con lista de procesos linkeados
|
||||||
|
9. `npm run build` — 0 errores TypeScript
|
||||||
|
10. `npm run lint` — 0 warnings nuevos
|
||||||
|
11. `npm run test` — mantener 528+ tests Vitest verdes
|
||||||
|
12. Commit: `feat(sprint-5/etapa-4): procesos por recurso + campo empresa en perfil`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Criterios de validación
|
||||||
|
|
||||||
|
- [ ] Columna "Procesos" visible en `/recursos` con conteo correcto (verificar con un recurso usado en ≥1 proceso)
|
||||||
|
- [ ] Recurso sin asignaciones muestra "—" en la columna Procesos
|
||||||
|
- [ ] Click en el badge expande la lista de procesos con nombres y links funcionales
|
||||||
|
- [ ] Click en un proceso navega al workspace correcto
|
||||||
|
- [ ] Migración SQL ejecutada sin errores; `SELECT company FROM users` no falla
|
||||||
|
- [ ] AppHeader muestra empresa si está definida (probar editando desde el Sheet)
|
||||||
|
- [ ] Sheet de perfil: guardar nombre y empresa persiste en Supabase y se refleja en el dropdown
|
||||||
|
- [ ] Email en el Sheet es solo lectura
|
||||||
|
- [ ] 528+ tests Vitest verdes
|
||||||
|
- [ ] **Validación visual del director** antes del OK formal
|
||||||
Reference in New Issue
Block a user