Files
inq-roi-simulador-web/sprints/sprint-5/ETAPA_4_PROMPT.md
Marcos Benítez 7ffab83caf
Some checks failed
/ Deploy to Cloudflare Pages (push) Has been cancelled
Feature A — Procesos por recurso (/recursos)
2026-06-20 00:07:46 -03:00

9.4 KiB

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:

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:

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
{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:

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
// 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:

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:

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:

// 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:

<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.

// 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