# 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: ```
{/* Nav tabs entre Organizations y Users */}
Organizaciones Usuarios
{/* Contenido de la page */}
``` --- ## Í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' && ( )} ``` --- ## 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.