# 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`)

```typescript
// 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.

```tsx
{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:
```typescript
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:**
```typescript
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:
```typescript
await supabase
  .from('processes')
  .update({ org_id: orgId })
  .eq('id', selectedProcessId)
```

**Usuarios de esta org:**
```typescript
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

```typescript
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:

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