This commit is contained in:
288
sprints/sprint-7/ETAPA_4_PROMPT.md
Normal file
288
sprints/sprint-7/ETAPA_4_PROMPT.md
Normal file
@@ -0,0 +1,288 @@
|
||||
# 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.
|
||||
Reference in New Issue
Block a user