Files
inq-roi-simulador-web/sprints/sprint-7/ETAPA_4_PROMPT.md
Marcos Benítez bc9f70419c
Some checks failed
/ Deploy to Cloudflare Pages (push) Has been cancelled
Resumen de lo entregado
2026-07-07 09:47:31 -03:00

9.3 KiB

Etapa 4 — Panel de administración de InQuality

Sprint: 7 Fecha: 2026-07-06 Alcance: nuevas rutas /admin/* con guard de rol + componentes de admin. Sin cambios en migraciones, Edge Functions, WorkspacePage, ReportPage, o LibraryPage.


Contexto

Las Edge Functions invite-user y revoke-user ya están desplegadas (Etapa 2). Esta etapa construye la UI que las consume. El acceso es exclusivo para platform_admin.

ANTES de empezar: leer src/router.tsx para entender el patrón de rutas y guards existentes (beforeLoad). La estructura de archivos debe seguir el patrón de features/ ya establecido.


Nuevas rutas

/admin                   → redirect a /admin/organizations
/admin/organizations     → lista de orgs clientes
/admin/organizations/$orgId   → detalle de org (procesos + usuarios)
/admin/users             → lista de todos los usuarios

Todas requieren platform_role === 'platform_admin' — si no, redirect a /.

Guard en TanStack Router (patrón beforeLoad)

// Crear layout route para /admin que incluya el guard:
beforeLoad: ({ context }) => {
  const user = context.auth?.user
  if (!user || user.platformRole !== 'platform_admin') {
    throw redirect({ to: '/' })
  }
}

Layout de admin

No crear un layout nuevo de toda la app. Usar el AppHeader existente. Las páginas de admin son pages normales con su propia estructura interna.

Estructura interna sugerida para cada page de admin:

<div className="max-w-6xl mx-auto px-6 py-8">
  {/* Nav tabs entre Organizations y Users */}
  <div className="flex gap-4 border-b mb-6">
    <NavLink to="/admin/organizations">Organizaciones</NavLink>
    <NavLink to="/admin/users">Usuarios</NavLink>
  </div>
  {/* Contenido de la page */}
</div>

Ícono admin en AppHeader

Agregar un ícono Shield (Lucide) visible solo cuando user.platformRole === 'platform_admin'. Posición: junto al avatar/dropdown, a la izquierda.

{user.platformRole === 'platform_admin' && (
  <Button variant="ghost" size="icon" asChild>
    <Link to="/admin">
      <Shield className="h-5 w-5 text-muted-foreground" />
    </Link>
  </Button>
)}

Página Organizations (/admin/organizations)

Lista de organizaciones (clientes únicamente — is_provider = false)

Query:

const { data: orgs } = await supabase
  .from('organizations')
  .select(`
    id, name, created_at,
    processes:processes(count),
    users:users(count)
  `)
  .eq('is_provider', false)
  .order('name')

Columnas de la tabla:

Organización Procesos Usuarios Creada Acciones
Banco Solar 3 2 12 jun 2026 Ver detalle

Crear organización

Botón "Nueva organización" → Sheet (no modal) con:

  • Campo Nombre (requerido, trim, no vacío)
  • Botón "Crear" → supabase.from('organizations').insert({ name, is_provider: false })
  • Al crear: cerrar sheet + refetch lista

Detalle de organización (/admin/organizations/$orgId)

Dos secciones en tabs o acordeón:

Procesos de esta org:

const { data: processes } = await supabase
  .from('processes')
  .select('id, name, client, updated_at')
  .eq('org_id', orgId)
  .order('updated_at', { ascending: false })

Mostrar como lista simple: nombre del proceso, fecha de última actualización, link al workspace.

Botón "Asignar proceso existente" → Sheet con dropdown de procesos sin org asignada (org_id IS NULL) + botón asignar:

await supabase
  .from('processes')
  .update({ org_id: orgId })
  .eq('id', selectedProcessId)

Usuarios de esta org:

const { data: users } = await supabase
  .from('users')
  .select('id, name, company, platform_role')
  .eq('org_id', orgId)
  .order('name')

Mostrar como lista: nombre, rol, acciones (ver Usuarios page para acciones).


Página Users (/admin/users)

Lista de usuarios

const { data: users } = await supabase
  .from('users')
  .select(`
    id, name, company, platform_role, org_id,
    organization:organizations(name, is_provider)
  `)
  .order('name')

Columnas:

Nombre Email* Rol Organización Estado Acciones

*Note: email está en auth.users, no en public.users. Para esta etapa, omitir el email en la lista o usar name únicamente. El email se puede agregar en sprint futuro via Edge Function o join con auth.users (solo disponible con service_role).

Badges de rol con color:

  • platform_admin → badge naranja (#F59845)
  • member → badge slate
  • client_editor → badge azul
  • client_viewer → badge verde claro
  • guest → badge gris, con indicador visual de "suspendido"

Filtro por organización

Dropdown "Filtrar por org" encima de la tabla. Opción "Todas". Al seleccionar una org, filtra con .eq('org_id', selectedOrgId).

Invitar usuario

Botón "Invitar usuario" → Sheet con campos:

Nombre completo: [__________________]
Email:           [__________________]
Rol:             [client_editor ▾]    (opciones: client_editor, client_viewer, member)
Organización:    [Banco Solar    ▾]   (solo si rol es client_editor o client_viewer)

Al submit → llamar Edge Function:

const { data, error } = await supabase.functions.invoke('invite-user', {
  body: {
    email,
    name,
    platform_role: rol,
    org_name: selectedOrg?.name ?? '',
  }
})

if (error) {
  // mostrar error inline
} else {
  // "Invitación enviada a {email}" — toast o inline
  // cerrar sheet + refetch lista
}

Nota: la Edge Function invite-user recibe org_name (string), no org_id. Busca o crea la org internamente.

Acciones por usuario (menú de 3 puntos o dropdown)

[ Cambiar rol ] → Sheet con dropdown de roles + botón guardar
                  → supabase.from('users').update({ platform_role: nuevoRol }).eq('id', userId)

[ Suspender   ] → cambia platform_role a 'guest' vía Edge Function revoke-user
                  Texto de confirmación: "¿Suspender a {nombre}? No podrá acceder a la plataforma."

[ Reactivar   ] → visible solo si platform_role === 'guest'
                  → supabase.from('users').update({ platform_role: 'client_editor' }).eq('id', userId)
                  IMPORTANTE: al reactivar, restaurar al rol anterior. Como no lo tenemos guardado,
                  restaurar a 'client_editor' como default. Documentar esto como limitación.

[ Eliminar    ] → confirmación con texto del nombre del usuario
                  → supabase.functions.invoke('revoke-user', { body: { userId } })
                  Nota: la función actual baja el rol a 'guest'. Eliminar real queda para sprint futuro.
                  Mostrar al admin: "Usuario suspendido permanentemente (eliminación real en versión futura)".

No permitir acciones sobre el usuario propio del admin — deshabilitar las acciones si user.id === item.id.


Versión wow (requerida si costo < 40%)

  • Loading skeletons en la tabla mientras carga (usar Skeleton de shadcn)
  • Empty state ilustrado: cuando no hay usuarios en la org, mostrar texto + icono, no tabla vacía
  • Confirmación de acciones destructivas con AlertDialog de shadcn (no window.confirm)
  • Badge de estado en la fila del usuario invitado recientemente: "Invitación pendiente" si el usuario no tiene name o avatar_url (indicador de que no aceptó el invite aún)
  • En el sheet de invite: deshabilitar el campo "Organización" cuando el rol es member, mostrar "InQuality" como valor fijo

Archivos a crear

src/features/admin/
├── AdminLayout.tsx          ← nav tabs, guard visual
├── OrganizationsPage.tsx    ← lista + crear
├── OrganizationDetailPage.tsx ← procesos + usuarios de la org
└── UsersPage.tsx            ← lista + invite + acciones

Agregar en src/router.tsx las 4 rutas con beforeLoad de guard.


Criterios de validación

  • Navegar a /admin sin ser platform_admin redirige a /
  • platform_admin ve el ícono Shield en AppHeader → navega a /admin
  • Lista de organizaciones muestra orgs clientes (no InQuality)
  • Crear organización → aparece en la lista sin recargar la página
  • Detalle de org muestra procesos y usuarios de esa org
  • Asignar proceso a org → proceso aparece en el detalle
  • Lista de usuarios muestra todos con badge de rol correcto
  • Filtro por org funciona
  • Invitar usuario llama la Edge Function y muestra confirmación
  • Suspender usuario: confirmación con AlertDialog, cambio a 'guest' efectivo
  • No se puede ejecutar ninguna acción sobre el propio usuario admin
  • Loading skeletons visibles durante fetch inicial
  • npm run test → 558+ tests verdes
  • npm run build limpio
  • Validación visual del director antes del OK formal

NO modificar

  • WorkspacePage, ReportPage, LibraryPage
  • Migraciones (001-015)
  • Edge Functions
  • Lógica de dominio, simulación, ROI
  • Componentes de PDF/CSV

Limitaciones conocidas a documentar en comentarios

  1. email no disponible en public.users — la columna vive en auth.users que requiere service_role. Mostrar solo name por ahora.
  2. "Eliminar" usuario usa revoke-user (baja a 'guest'). La eliminación real requiere una Edge Function delete-user futura.
  3. "Reactivar" restaura siempre a client_editor — no se guarda el rol previo a la suspensión.