289 lines
9.3 KiB
Markdown
289 lines
9.3 KiB
Markdown
# 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.
|