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

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.