Compare commits
20 Commits
de00e3a902
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 8b839baf77 | |||
| 1032944f8b | |||
| 0257dd95ec | |||
| fdbdc42470 | |||
| 3044342ac1 | |||
| 85190aef0c | |||
| 789e9a34ea | |||
| bff71f0f29 | |||
| 70d35dad8b | |||
| 54306be1c2 | |||
| d776af5f24 | |||
| cc5563974a | |||
| c6c88501d1 | |||
| 1813a9396a | |||
| b149e1cd0c | |||
| 53f8a8d8cd | |||
| 6e60bf4def | |||
| fe33c910ac | |||
| b634d061b7 | |||
| e0b4607603 |
25
CHANGELOG.md
25
CHANGELOG.md
@@ -7,6 +7,31 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
### Added
|
||||
- `RequireRole` componente wrapper para guards de rol declarativos (Approach B — sin router context) (Sprint 8)
|
||||
- Edge Function `delete-user`: hard delete permanente de usuario en auth.users + public.users (Sprint 8)
|
||||
- Edge Function `list-users`: lista usuarios con email desde auth.users vía service_role (Sprint 8)
|
||||
- UsersPage: columna "Email" entre Nombre y Rol (via Edge Function list-users) (Sprint 8)
|
||||
- Migración 018: policy INSERT en `processes` extendida para `client_editor` en su org (Sprint 8)
|
||||
- ImportPage: `client_editor` puede importar BPMN; proceso se crea con `orgId` de su cuenta (Sprint 8)
|
||||
- LibraryPage: botón "Importar BPMN" visible para `client_editor`; empty state con call-to-action de importar (Sprint 8)
|
||||
|
||||
### Changed
|
||||
- AdminLayout: guard useEffect + spinner → RequireRole wrapper (sin flash de contenido protegido) (Sprint 8)
|
||||
- ImportPage: guard useEffect → RequireRole wrapper (Sprint 8)
|
||||
- UsersPage: "Eliminar" (soft delete / revoke-user) → "Eliminar permanentemente" (hard delete / delete-user) (Sprint 8)
|
||||
|
||||
### Fixed
|
||||
- ImportPage: texto de marca corregido — "Process Cost Platform · MVP Fase 0" → "InQ ROI" (Sprint 8)
|
||||
- ImportPage: párrafo "Sin backend · Sin registro · Datos guardados localmente" eliminado (obsoleto desde Sprint 4) (Sprint 8)
|
||||
- OrganizationsPage: InQuality visible en lista con badge naranja — quitar filtro is_provider (Sprint 8)
|
||||
- WorkspacePage: back button pasa ?org=orgId para scroll contextual al volver a biblioteca (Sprint 8)
|
||||
- LibraryPage: scroll automático a sección de org al recibir ?org param desde workspace (Sprint 8)
|
||||
- router.tsx: redirect de settingsRoute pasa search: { org: undefined } para compatibilidad con validateSearch (Sprint 8)
|
||||
|
||||
### Added (Etapas 1-4)
|
||||
- GlobalSettingsPanel: selector de área reemplaza campo libre "Cliente" (Sprint 8)
|
||||
- WorkspacePage topbar: display de `areaName` de solo lectura reemplaza edición inline de cliente (Sprint 8)
|
||||
- OrganizationDetailPage: tercer tab "Áreas" con CRUD completo — crear, renombrar, eliminar con confirmación (Sprint 8)
|
||||
- LibraryPage: agrupación org → área con headers colapsables para admin (Sprint 8)
|
||||
- Settings ⚙ oculto para roles cliente + guard beforeLoad en /settings (Sprint 8)
|
||||
- Interface `Area` en domain/types.ts (Sprint 8)
|
||||
|
||||
266
sprints/sprint-8/ETAPA_4_5_PROMPT.md
Normal file
266
sprints/sprint-8/ETAPA_4_5_PROMPT.md
Normal file
@@ -0,0 +1,266 @@
|
||||
# Sprint 8 — Etapa 4.5: Fixes de navegación e InQuality
|
||||
|
||||
> **Tipo:** UI — fixes puntuales
|
||||
> **Rama:** main
|
||||
> **Estimación:** 1 hora
|
||||
> **Dependencias:** Etapa 4 completada ✅
|
||||
|
||||
---
|
||||
|
||||
## Objetivo
|
||||
|
||||
Dos fixes independientes detectados en validación de Etapa 4:
|
||||
|
||||
1. **InQuality invisible en admin:** `OrganizationsPage` filtra con `.eq('is_provider', false)`, dejando fuera a InQuality. El equipo no puede gestionar sus propias áreas ni procesos desde el panel. Fix: mostrar todas las orgs, con un badge visual para InQuality.
|
||||
|
||||
2. **Back button sin contexto de org:** En el workspace, el botón "← Solar Banco" (o cualquier org) navega a `/library` sin parámetros — el usuario llega al home genérico en lugar de la sección de esa org. Fix: pasar `?org=<orgId>` al navegar y hacer scroll al bloque correcto en LibraryPage.
|
||||
|
||||
---
|
||||
|
||||
## Fix 1: `OrganizationsPage.tsx`
|
||||
|
||||
### Problema actual
|
||||
|
||||
Línea en `fetchOrgs()`:
|
||||
```typescript
|
||||
.eq('is_provider', false)
|
||||
```
|
||||
|
||||
Esta condición excluye a InQuality (que tiene `is_provider = true`) de la lista. El equipo de InQuality no puede entrar a `/admin/organizations/:id` de su propia org para gestionar áreas y procesos.
|
||||
|
||||
### Cambios en `OrganizationsPage.tsx`
|
||||
|
||||
**1A. Extender la interface `OrgRow`:**
|
||||
|
||||
```typescript
|
||||
interface OrgRow {
|
||||
id: string
|
||||
name: string
|
||||
is_provider: boolean // 🆕
|
||||
created_at: string
|
||||
processes: [{ count: number }]
|
||||
users: [{ count: number }]
|
||||
}
|
||||
```
|
||||
|
||||
**1B. Actualizar `fetchOrgs()` — quitar el filtro y agregar `is_provider` al select:**
|
||||
|
||||
```typescript
|
||||
const fetchOrgs = async () => {
|
||||
const { data } = await supabase
|
||||
.from('organizations')
|
||||
.select('id, name, is_provider, created_at, processes:processes(count), users:users(count)')
|
||||
// ← eliminar el .eq('is_provider', false)
|
||||
.order('name')
|
||||
|
||||
// Ordenar: proveedor primero, luego clientes alfabético
|
||||
const sorted = ((data as OrgRow[] | null) ?? []).sort((a, b) => {
|
||||
if (a.is_provider && !b.is_provider) return -1
|
||||
if (!a.is_provider && b.is_provider) return 1
|
||||
return a.name.localeCompare(b.name)
|
||||
})
|
||||
|
||||
setOrgs(sorted)
|
||||
setLoading(false)
|
||||
}
|
||||
```
|
||||
|
||||
**1C. Actualizar el título:**
|
||||
|
||||
```tsx
|
||||
// Antes:
|
||||
<h2 className="text-lg font-semibold text-slate-900">Organizaciones clientes</h2>
|
||||
|
||||
// Después:
|
||||
<h2 className="text-lg font-semibold text-slate-900">Organizaciones</h2>
|
||||
```
|
||||
|
||||
**1D. Agregar badge "Proveedor" en la columna de nombre:**
|
||||
|
||||
En la fila de la tabla, en la celda de nombre:
|
||||
```tsx
|
||||
<td className="py-3 px-4 font-medium text-slate-900">
|
||||
<div className="flex items-center gap-2">
|
||||
{org.name}
|
||||
{org.is_provider && (
|
||||
<span className="text-[10px] font-semibold px-1.5 py-0.5 rounded-full border"
|
||||
style={{ color: '#F59845', background: '#fff8f0', borderColor: '#fcd9aa' }}>
|
||||
InQuality
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
```
|
||||
|
||||
No cambiar el botón "Ver detalle →" — funciona igual para InQuality que para cualquier otra org.
|
||||
|
||||
---
|
||||
|
||||
## Fix 2: Back button con contexto de org
|
||||
|
||||
Son tres cambios coordinados: `router.tsx`, `WorkspacePage.tsx`, y `LibraryPage.tsx`.
|
||||
|
||||
### Fix 2A: `router.tsx` — declarar `org` como search param de `/library`
|
||||
|
||||
TanStack Router requiere que los search params estén declarados en la ruta para que `navigate({ search: ... })` sea type-safe.
|
||||
|
||||
```typescript
|
||||
// Antes:
|
||||
const libraryRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/library',
|
||||
component: LibraryPage,
|
||||
})
|
||||
|
||||
// Después:
|
||||
const libraryRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/library',
|
||||
component: LibraryPage,
|
||||
validateSearch: (search: Record<string, unknown>) => ({
|
||||
org: typeof search.org === 'string' ? search.org : undefined,
|
||||
}),
|
||||
})
|
||||
```
|
||||
|
||||
**NO tocar** `indexRoute` (`/`) — no necesita el param porque nunca se navega a `/` desde el workspace.
|
||||
|
||||
### Fix 2B: `WorkspacePage.tsx` — pasar org en `handleBack`
|
||||
|
||||
```typescript
|
||||
// Antes (línea ~194-196):
|
||||
function handleBack() {
|
||||
void navigate({ to: '/library' })
|
||||
}
|
||||
|
||||
// Después:
|
||||
function handleBack() {
|
||||
void navigate({
|
||||
to: '/library',
|
||||
search: currentProcess.orgId ? { org: currentProcess.orgId } : {},
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
### Fix 2C: `LibraryPage.tsx` — leer param y hacer scroll
|
||||
|
||||
**Imports:** agregar `useLocation` al import existente de `@tanstack/react-router`:
|
||||
|
||||
```typescript
|
||||
// Antes:
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
|
||||
// Después:
|
||||
import { useNavigate, useLocation } from '@tanstack/react-router'
|
||||
```
|
||||
|
||||
**Leer el param** (agregar dentro del cuerpo de `LibraryPage`, junto a los otros hooks):
|
||||
|
||||
```typescript
|
||||
const location = useLocation()
|
||||
const orgParam = useMemo(
|
||||
() => new URLSearchParams(location.search).get('org') ?? undefined,
|
||||
[location.search]
|
||||
)
|
||||
```
|
||||
|
||||
`useMemo` ya está importado en el archivo.
|
||||
|
||||
**Efecto de scroll** (agregar después de los efectos existentes):
|
||||
|
||||
```typescript
|
||||
// Scroll a la sección del org si venimos del workspace
|
||||
useEffect(() => {
|
||||
if (!orgParam || isLoading || processes.length === 0) return
|
||||
requestAnimationFrame(() => {
|
||||
const el = document.getElementById(`org-section-${orgParam}`)
|
||||
el?.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||
})
|
||||
}, [orgParam, isLoading, processes.length])
|
||||
```
|
||||
|
||||
**Agregar `id` al div de cada org en el bloque admin** (en el `orgGroups.map(...)`):
|
||||
|
||||
```tsx
|
||||
// Antes:
|
||||
<div key={orgId} className="border border-border rounded-xl overflow-hidden">
|
||||
|
||||
// Después:
|
||||
<div key={orgId} id={`org-section-${orgId}`} className="border border-border rounded-xl overflow-hidden">
|
||||
```
|
||||
|
||||
Solo en el div raíz de cada org (el que tiene el `key={orgId}`). No tocar divs internos.
|
||||
|
||||
---
|
||||
|
||||
## Lo que NO cambia en esta etapa
|
||||
|
||||
- **`src/features/admin/OrganizationDetailPage.tsx`** — sin cambios; el detalle de InQuality ya funciona una vez que aparece en la lista.
|
||||
- **`src/store/library-store.ts`** — sin cambios.
|
||||
- **Cualquier otro archivo** — sin cambios.
|
||||
|
||||
---
|
||||
|
||||
## Criterios de validación
|
||||
|
||||
### Fix 1 — InQuality visible
|
||||
- [ ] La página `/admin/organizations` muestra a InQuality en la lista
|
||||
- [ ] InQuality aparece primero (antes que las orgs clientes)
|
||||
- [ ] InQuality tiene el badge naranja "InQuality"
|
||||
- [ ] Al hacer click en "Ver detalle →" de InQuality, lleva a su detalle con procesos, usuarios y áreas
|
||||
- [ ] Las otras orgs clientes siguen apareciendo igual que antes
|
||||
|
||||
### Fix 2 — Back button con scroll
|
||||
- [ ] Abrir un proceso de "Solar Banco" → workspace → botón "← Solar Banco" → navega a `/library?org=<orgId>`
|
||||
- [ ] Al llegar a `/library`, la página hace scroll automático al bloque de "Solar Banco"
|
||||
- [ ] El bloque de "Solar Banco" está expandido (todas las orgs empiezan expandidas por default)
|
||||
- [ ] Si el proceso no tiene org (edge case), el back button va a `/library` sin param y no hay scroll
|
||||
|
||||
### Técnicos
|
||||
- [ ] `npm run build` sin errores TypeScript
|
||||
- [ ] `npm run test` sin regresiones (596 tests)
|
||||
|
||||
---
|
||||
|
||||
## Restricciones explícitas
|
||||
|
||||
- **NO agregar** `validateSearch` a `indexRoute` (`/`) — solo a `libraryRoute`
|
||||
- **NO modificar** `OrganizationDetailPage.tsx` en esta etapa
|
||||
- **NO cambiar** el comportamiento del botón "Nueva organización" — InQuality se crea con `is_provider: false`, InQuality nunca se crea desde la UI (es dato de seeding)
|
||||
- **NO agregar** tests en esta etapa
|
||||
|
||||
---
|
||||
|
||||
## Archivos autorizados a modificar
|
||||
|
||||
```
|
||||
src/
|
||||
├── router.tsx ← validateSearch en libraryRoute
|
||||
├── features/
|
||||
│ ├── workspace/
|
||||
│ │ └── WorkspacePage.tsx ← handleBack con search: { org }
|
||||
│ ├── library/
|
||||
│ │ └── LibraryPage.tsx ← useLocation + scroll effect + id en org divs
|
||||
│ └── admin/
|
||||
│ └── OrganizationsPage.tsx ← quitar filtro is_provider + badge + título
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Commits esperados
|
||||
|
||||
```
|
||||
fix(admin): InQuality visible en OrganizationsPage con badge — quitar filtro is_provider
|
||||
fix(workspace): handleBack pasa ?org=orgId para scroll contextual en biblioteca
|
||||
fix(library): scroll a sección de org al volver del workspace con ?org param
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Reporte esperado
|
||||
|
||||
1. Confirmar que InQuality aparece en la lista de organizaciones con el badge
|
||||
2. Confirmar que el back button del workspace pasa `?org=` en la URL
|
||||
3. Confirmar que la biblioteca hace scroll al bloque correcto al recibir el param
|
||||
4. `npm run build` y `npm run test` (con conteo)
|
||||
5. **Solicitar validación visual del director** en los dos fixes antes del OK formal
|
||||
622
sprints/sprint-8/ETAPA_4_PROMPT.md
Normal file
622
sprints/sprint-8/ETAPA_4_PROMPT.md
Normal file
@@ -0,0 +1,622 @@
|
||||
# Sprint 8 — Etapa 4: Selector de área en workspace + CRUD de áreas en admin
|
||||
|
||||
> **Tipo:** UI — workspace sidebar + panel de administración
|
||||
> **Rama:** main
|
||||
> **Estimación:** 2-3 horas
|
||||
> **Dependencias:** Etapas 1, 2 y 3 completadas ✅
|
||||
> (tabla `areas` en DB, `area-repo.ts` con CRUD, `areaId`/`areaName` en tipos y repos)
|
||||
|
||||
---
|
||||
|
||||
## Objetivo
|
||||
|
||||
Conectar el campo `area_id` al flujo de trabajo real del usuario en dos lugares:
|
||||
|
||||
1. **Workspace sidebar (`GlobalSettingsPanel.tsx`):** reemplazar el campo de texto libre "Cliente" por un `<Select>` que lista las áreas de la org del proceso. El admin puede asignar/cambiar el área de un proceso desde aquí.
|
||||
2. **WorkspacePage.tsx:** reemplazar la edición inline de `clientName` en el topbar por un display de solo lectura del `areaName`.
|
||||
3. **OrganizationDetailPage.tsx:** agregar un tercer tab "Áreas" con CRUD completo — crear, renombrar, eliminar (con confirmación y conteo de procesos afectados).
|
||||
|
||||
Al terminar esta etapa, `area_id` es un campo vivo: se puede asignar desde el workspace y administrar desde el panel de admin.
|
||||
|
||||
---
|
||||
|
||||
## Contexto del código actual
|
||||
|
||||
Leer en su totalidad antes de modificar:
|
||||
|
||||
- `src/features/workspace/GlobalSettingsPanel.tsx` — campo "Cliente" con `localClient` state, `Input` editable para admin, `<p>` read-only para cliente.
|
||||
- `src/features/workspace/WorkspacePage.tsx` — bloque "Cliente — editable inline" en el topbar (líneas ~254-276), función `saveField('client')` (línea ~125).
|
||||
- `src/features/admin/OrganizationDetailPage.tsx` — dos tabs existentes: Procesos y Usuarios. Usa `Sheet` y `Select` de shadcn ya importados.
|
||||
- `src/persistence/supabase/area-repo.ts` — CRUD completo ya implementado en Etapa 2. Función relevante: `supabaseAreaRepo.getByOrg(orgId)`.
|
||||
- `src/domain/types.ts` — `Process` ya tiene `areaId: string | null` y `areaName?: string | null`.
|
||||
- `src/persistence/supabase/process-repo.ts` — `updateEditable()` ya incluye `area_id: process.areaId`.
|
||||
|
||||
**Estado del store:** `updateProcess` en `process-store.ts` acepta updates parciales — mergea el parcial con el proceso actual y llama `updateEditable()`. No es necesario modificar el store.
|
||||
|
||||
---
|
||||
|
||||
## Parte A: `GlobalSettingsPanel.tsx` — selector de área
|
||||
|
||||
### Cambios de estado
|
||||
|
||||
**Eliminar:**
|
||||
```typescript
|
||||
const [localClient, setLocalClient] = useState('')
|
||||
```
|
||||
|
||||
**Agregar:**
|
||||
```typescript
|
||||
const [localAreaId, setLocalAreaId] = useState<string>('') // '' = sin área (null en DB)
|
||||
const [areas, setAreas] = useState<Area[]>([])
|
||||
const [areasLoading, setAreasLoading] = useState(false)
|
||||
```
|
||||
|
||||
Usar `''` (string vacío) como sentinel para `null` — el `<Select>` de shadcn maneja strings.
|
||||
|
||||
### Imports a agregar
|
||||
|
||||
```typescript
|
||||
import { supabaseAreaRepo } from '@/persistence/supabase/area-repo'
|
||||
import type { Area } from '@/domain/types'
|
||||
```
|
||||
|
||||
### Actualizar el `useEffect` principal
|
||||
|
||||
En el `useEffect` que depende de `[currentProcess]`, reemplazar la línea `setLocalClient(currentProcess.clientName)` por:
|
||||
|
||||
```typescript
|
||||
setLocalAreaId(currentProcess.areaId ?? '')
|
||||
|
||||
// Cargar áreas de la org del proceso
|
||||
if (currentProcess.orgId) {
|
||||
setAreasLoading(true)
|
||||
supabaseAreaRepo.getByOrg(currentProcess.orgId)
|
||||
.then(setAreas)
|
||||
.catch(() => setAreas([]))
|
||||
.finally(() => setAreasLoading(false))
|
||||
} else {
|
||||
setAreas([])
|
||||
}
|
||||
```
|
||||
|
||||
### Actualizar `handleSave`
|
||||
|
||||
**Eliminar:**
|
||||
```typescript
|
||||
clientName: localClient.trim(),
|
||||
```
|
||||
|
||||
**Agregar:**
|
||||
```typescript
|
||||
areaId: localAreaId === '' ? null : localAreaId,
|
||||
```
|
||||
|
||||
El campo `clientName` ya no se pasa en `handleSave` — el store conservará el valor existente en el proceso (el campo persiste en DB vía el objeto actual del proceso, sin cambios desde esta pantalla).
|
||||
|
||||
### Reemplazar el bloque JSX del campo "Cliente"
|
||||
|
||||
**Antes (campo texto libre "Cliente"):**
|
||||
```tsx
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-medium">Cliente</Label>
|
||||
{isClientRole ? (
|
||||
<p className="text-sm text-slate-600 h-8 flex items-center px-1">{localClient || '—'}</p>
|
||||
) : (
|
||||
<Input
|
||||
id="client-name"
|
||||
value={localClient}
|
||||
onChange={(e) => { setLocalClient(e.target.value); markDirty() }}
|
||||
placeholder="ej. Empresa ABC"
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
```
|
||||
|
||||
**Después (selector de área):**
|
||||
```tsx
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-medium">Área</Label>
|
||||
{areasLoading ? (
|
||||
<div className="h-8 bg-slate-100 animate-pulse rounded-md" />
|
||||
) : !currentProcess.orgId ? (
|
||||
<p className="text-sm text-slate-400 h-8 flex items-center px-1 italic">Sin organización asignada</p>
|
||||
) : isClientRole && user?.platformRole === 'client_viewer' ? (
|
||||
<p className="text-sm text-slate-600 h-8 flex items-center px-1">{currentProcess.areaName || '—'}</p>
|
||||
) : (
|
||||
<Select
|
||||
value={localAreaId}
|
||||
onValueChange={(v) => { setLocalAreaId(v); markDirty() }}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder="Sin área" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="" className="text-xs text-slate-400 italic">Sin área</SelectItem>
|
||||
{areas.map((a) => (
|
||||
<SelectItem key={a.id} value={a.id} className="text-xs">{a.name}</SelectItem>
|
||||
))}
|
||||
{areas.length === 0 && (
|
||||
<div className="px-2 py-1.5 text-xs text-slate-400">
|
||||
No hay áreas en esta organización
|
||||
</div>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
```
|
||||
|
||||
**Notas:**
|
||||
- `client_viewer` nunca llega al workspace (redirigido en WorkspacePage). La condición `isClientRole && user?.platformRole === 'client_viewer'` es por seguridad.
|
||||
- `client_editor` sí puede seleccionar área — la RLS de `areas_select` le retorna las áreas de su org.
|
||||
- Si `currentProcess.orgId` es null (edge case post-migración), mostrar texto descriptivo en lugar del dropdown.
|
||||
- `Select` ya está importado en el archivo.
|
||||
|
||||
### Verificar que `Select` y sus sub-componentes estén importados
|
||||
|
||||
Ya están importados en `GlobalSettingsPanel.tsx` (`Select`, `SelectContent`, `SelectItem`, `SelectTrigger`, `SelectValue`). Confirmar antes de agregar duplicados.
|
||||
|
||||
---
|
||||
|
||||
## Parte B: `WorkspacePage.tsx` — display de areaName en topbar
|
||||
|
||||
### Qué cambiar
|
||||
|
||||
Reemplazar el bloque "Cliente — editable inline" (~líneas 254-276) en el topbar.
|
||||
|
||||
**Antes (edición inline de clientName):**
|
||||
```tsx
|
||||
{/* Cliente — editable inline solo para roles internos */}
|
||||
{user?.platformRole === 'client_editor' ? (
|
||||
currentProcess.clientName && (
|
||||
<p className="text-xs text-slate-400 truncate">{currentProcess.clientName}</p>
|
||||
)
|
||||
) : editingField === 'client' ? (
|
||||
<input
|
||||
autoFocus
|
||||
className="text-xs text-slate-400 bg-transparent border-b border-[#F59845] outline-none w-full"
|
||||
value={editValue}
|
||||
onChange={(e) => setEditValue(e.target.value)}
|
||||
onBlur={() => saveField('client')}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') { e.preventDefault(); saveField('client') }
|
||||
if (e.key === 'Escape') setEditingField(null)
|
||||
}}
|
||||
placeholder="Agregar cliente…"
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className="flex items-center gap-1 group cursor-text"
|
||||
onClick={() => { setEditValue(currentProcess.clientName ?? ''); setEditingField('client') }}
|
||||
>
|
||||
{currentProcess.clientName
|
||||
? <p className="text-xs text-slate-400 truncate">{currentProcess.clientName}</p>
|
||||
: <p className="text-xs text-slate-300 truncate italic">Agregar cliente…</p>
|
||||
}
|
||||
{savedField === 'client'
|
||||
? <Check className="h-3 w-3 text-[#F59845] shrink-0" />
|
||||
: <Pencil className="h-2.5 w-2.5 text-slate-300 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
}
|
||||
</div>
|
||||
)}
|
||||
```
|
||||
|
||||
**Después (display de areaName, solo lectura):**
|
||||
```tsx
|
||||
{/* Área — display de solo lectura (editable desde el panel de configuración) */}
|
||||
{currentProcess.areaName && (
|
||||
<p className="text-xs text-slate-400 truncate">{currentProcess.areaName}</p>
|
||||
)}
|
||||
```
|
||||
|
||||
### Lo que NO cambia en WorkspacePage.tsx
|
||||
|
||||
- La función `saveField` — puede quedar con el caso `'client'` como código muerto, no eliminar.
|
||||
- El tipo de `editingField` — no es necesario actualizar si es genérico.
|
||||
- El bloque de edición inline del **nombre** del proceso — sin cambios.
|
||||
- Todo el resto del archivo.
|
||||
|
||||
**Restricción explícita:** NO eliminar la función `saveField` ni el estado `editingField`. Solo reemplazar el bloque JSX del cliente. El `saveField('client')` queda como código muerto — menos riesgo que eliminar y romper algo.
|
||||
|
||||
---
|
||||
|
||||
## Parte C: `OrganizationDetailPage.tsx` — CRUD de áreas
|
||||
|
||||
### Estructura de estado a agregar
|
||||
|
||||
```typescript
|
||||
// Áreas de la org
|
||||
const [areas, setAreas] = useState<AreaWithCount[]>([])
|
||||
const [areasLoading, setAreasLoading] = useState(false)
|
||||
|
||||
// Sheet crear/editar área
|
||||
const [areaSheetOpen, setAreaSheetOpen] = useState(false)
|
||||
const [editingAreaId, setEditingAreaId] = useState<string | null>(null) // null = crear nuevo
|
||||
const [areaNameInput, setAreaNameInput] = useState('')
|
||||
const [savingArea, setSavingArea] = useState(false)
|
||||
const [areaFormError, setAreaFormError] = useState<string | null>(null)
|
||||
|
||||
// Confirmación de delete
|
||||
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null)
|
||||
const [deletingAreaId, setDeletingAreaId] = useState<string | null>(null)
|
||||
```
|
||||
|
||||
### Interfaces locales a agregar (top del archivo)
|
||||
|
||||
```typescript
|
||||
interface AreaWithCount {
|
||||
id: string
|
||||
name: string
|
||||
description: string | null
|
||||
processCount: number
|
||||
}
|
||||
```
|
||||
|
||||
Y extender `OrgProcess` con `area_id`:
|
||||
```typescript
|
||||
interface OrgProcess {
|
||||
id: string
|
||||
name: string
|
||||
client: string | null
|
||||
area_id: string | null // 🆕 para calcular processCount por área
|
||||
updated_at: string
|
||||
}
|
||||
```
|
||||
|
||||
### Import a agregar
|
||||
|
||||
```typescript
|
||||
import { supabaseAreaRepo } from '@/persistence/supabase/area-repo'
|
||||
import { FolderOpen, Plus, Pencil, Trash2 } from 'lucide-react'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
```
|
||||
|
||||
Verificar qué ya está importado antes de agregar duplicados.
|
||||
|
||||
### Actualizar `fetchDetail`
|
||||
|
||||
Extender el `Promise.all` para incluir la carga de áreas:
|
||||
|
||||
```typescript
|
||||
const fetchDetail = async () => {
|
||||
const [orgRes, procRes, userRes] = await Promise.all([
|
||||
supabase.from('organizations').select('name').eq('id', orgId).single(),
|
||||
supabase
|
||||
.from('processes')
|
||||
.select('id, name, client, area_id, updated_at') // ← agregar area_id
|
||||
.eq('org_id', orgId)
|
||||
.order('updated_at', { ascending: false }),
|
||||
supabase.from('users').select('id, name, company, platform_role, avatar_url').eq('org_id', orgId).order('name'),
|
||||
])
|
||||
if (orgRes.data) setOrgName((orgRes.data as { name: string }).name)
|
||||
const loadedProcesses = (procRes.data as OrgProcess[] | null) ?? []
|
||||
setProcesses(loadedProcesses)
|
||||
setUsers((userRes.data as OrgUser[] | null) ?? [])
|
||||
setLoading(false)
|
||||
|
||||
// Cargar áreas y calcular conteo de procesos
|
||||
setAreasLoading(true)
|
||||
try {
|
||||
const rawAreas = await supabaseAreaRepo.getByOrg(orgId)
|
||||
const withCount: AreaWithCount[] = rawAreas.map((a) => ({
|
||||
id: a.id,
|
||||
name: a.name,
|
||||
description: a.description,
|
||||
processCount: loadedProcesses.filter((p) => p.area_id === a.id).length,
|
||||
}))
|
||||
setAreas(withCount)
|
||||
} catch {
|
||||
setAreas([])
|
||||
} finally {
|
||||
setAreasLoading(false)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Nota:** las áreas se cargan después de los procesos para poder calcular el conteo con un solo fetch.
|
||||
|
||||
### Funciones CRUD de áreas
|
||||
|
||||
```typescript
|
||||
const openCreateArea = () => {
|
||||
setEditingAreaId(null)
|
||||
setAreaNameInput('')
|
||||
setAreaFormError(null)
|
||||
setAreaSheetOpen(true)
|
||||
}
|
||||
|
||||
const openEditArea = (area: AreaWithCount) => {
|
||||
setEditingAreaId(area.id)
|
||||
setAreaNameInput(area.name)
|
||||
setAreaFormError(null)
|
||||
setAreaSheetOpen(true)
|
||||
}
|
||||
|
||||
const handleSaveArea = async () => {
|
||||
const name = areaNameInput.trim()
|
||||
if (!name) { setAreaFormError('El nombre del área es requerido'); return }
|
||||
setSavingArea(true)
|
||||
setAreaFormError(null)
|
||||
try {
|
||||
if (editingAreaId) {
|
||||
await supabaseAreaRepo.update(editingAreaId, { name })
|
||||
} else {
|
||||
await supabaseAreaRepo.create({ orgId, name })
|
||||
}
|
||||
setAreaSheetOpen(false)
|
||||
void fetchDetail()
|
||||
} catch (e) {
|
||||
setAreaFormError(e instanceof Error ? e.message : 'Error al guardar el área')
|
||||
} finally {
|
||||
setSavingArea(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteArea = async (areaId: string) => {
|
||||
setDeletingAreaId(areaId)
|
||||
try {
|
||||
await supabaseAreaRepo.delete(areaId)
|
||||
setDeleteConfirmId(null)
|
||||
void fetchDetail()
|
||||
} catch (e) {
|
||||
console.error('Error eliminando área:', e)
|
||||
} finally {
|
||||
setDeletingAreaId(null)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Agregar tercer tab "Áreas"
|
||||
|
||||
En `<TabsList>`, agregar después del tab de Usuarios:
|
||||
```tsx
|
||||
<TabsTrigger value="areas">
|
||||
<FolderOpen className="h-4 w-4 mr-1.5" />
|
||||
Áreas ({areas.length})
|
||||
</TabsTrigger>
|
||||
```
|
||||
|
||||
Y el contenido del tab:
|
||||
|
||||
```tsx
|
||||
{/* ─── Tab: Áreas ──────────────────────────────────────────────── */}
|
||||
<TabsContent value="areas">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<p className="text-sm text-slate-500">
|
||||
{areasLoading
|
||||
? 'Cargando áreas…'
|
||||
: areas.length === 0
|
||||
? 'No hay áreas en esta organización.'
|
||||
: `${areas.length} área${areas.length !== 1 ? 's' : ''}.`}
|
||||
</p>
|
||||
<Button size="sm" variant="outline" onClick={openCreateArea}>
|
||||
<Plus className="h-4 w-4 mr-1.5" />
|
||||
Nueva área
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{areasLoading ? (
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-12 w-full" />
|
||||
<Skeleton className="h-12 w-full" />
|
||||
</div>
|
||||
) : areas.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={<FolderOpen className="h-10 w-10 text-slate-300" />}
|
||||
message="Las áreas agrupan procesos dentro de una organización (ej: Operaciones, RR.HH., Finanzas)."
|
||||
/>
|
||||
) : (
|
||||
<div className="rounded-lg border border-slate-200 overflow-hidden bg-white">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-slate-50 border-b border-slate-200">
|
||||
<tr>
|
||||
<th className="text-left py-2.5 px-4 font-medium text-slate-600">Área</th>
|
||||
<th className="text-left py-2.5 px-4 font-medium text-slate-600">Procesos</th>
|
||||
<th className="py-2.5 px-4" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{areas.map((area) => (
|
||||
<tr key={area.id} className="border-b border-slate-100 last:border-0 hover:bg-slate-50/50">
|
||||
<td className="py-3 px-4 font-medium text-slate-900">{area.name}</td>
|
||||
<td className="py-3 px-4 text-slate-500">
|
||||
{area.processCount} {area.processCount === 1 ? 'proceso' : 'procesos'}
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
{deleteConfirmId === area.id ? (
|
||||
/* Confirmación inline */
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<span className="text-xs text-slate-500">
|
||||
{area.processCount > 0
|
||||
? `${area.processCount} proceso${area.processCount !== 1 ? 's' : ''} quedarán sin área.`
|
||||
: '¿Eliminar esta área?'}
|
||||
</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
className="h-7 text-xs px-2"
|
||||
disabled={deletingAreaId === area.id}
|
||||
onClick={() => handleDeleteArea(area.id)}
|
||||
>
|
||||
{deletingAreaId === area.id ? 'Eliminando…' : 'Confirmar'}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 text-xs px-2"
|
||||
onClick={() => setDeleteConfirmId(null)}
|
||||
>
|
||||
Cancelar
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 w-7 p-0 text-slate-400 hover:text-slate-700"
|
||||
onClick={() => openEditArea(area)}
|
||||
title="Renombrar área"
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 w-7 p-0 text-slate-400 hover:text-red-600"
|
||||
onClick={() => setDeleteConfirmId(area.id)}
|
||||
title="Eliminar área"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
```
|
||||
|
||||
### Agregar Sheet para crear/editar área
|
||||
|
||||
Después del Sheet existente de "Asignar proceso", agregar:
|
||||
|
||||
```tsx
|
||||
{/* ─── Sheet: Crear/editar área ────────────────────────────────── */}
|
||||
<Sheet open={areaSheetOpen} onOpenChange={setAreaSheetOpen}>
|
||||
<SheetContent>
|
||||
<SheetHeader>
|
||||
<SheetTitle>
|
||||
{editingAreaId ? 'Renombrar área' : `Nueva área en ${orgName}`}
|
||||
</SheetTitle>
|
||||
</SheetHeader>
|
||||
<div className="mt-5 flex flex-col gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="area-name" className="text-sm font-medium">
|
||||
Nombre del área
|
||||
</Label>
|
||||
<Input
|
||||
id="area-name"
|
||||
value={areaNameInput}
|
||||
onChange={(e) => { setAreaNameInput(e.target.value); setAreaFormError(null) }}
|
||||
placeholder="ej. Operaciones, RR.HH., Finanzas"
|
||||
className="h-9"
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') void handleSaveArea() }}
|
||||
autoFocus
|
||||
/>
|
||||
{areaFormError && (
|
||||
<p className="text-xs text-red-600">{areaFormError}</p>
|
||||
)}
|
||||
</div>
|
||||
<SheetFooter>
|
||||
<Button onClick={handleSaveArea} disabled={savingArea || !areaNameInput.trim()}>
|
||||
{savingArea ? 'Guardando…' : editingAreaId ? 'Guardar cambios' : 'Crear área'}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Lo que NO cambia en esta etapa
|
||||
|
||||
- **`src/domain/types.ts`** — no tocar (ya tiene `areaId`, `areaName`)
|
||||
- **`src/persistence/supabase/area-repo.ts`** — no tocar (CRUD ya implementado)
|
||||
- **`src/persistence/supabase/process-repo.ts`** — no tocar
|
||||
- **`src/store/process-store.ts`** — no tocar
|
||||
- **`src/features/library/LibraryPage.tsx`** — no tocar
|
||||
- **`src/features/settings/SettingsPage.tsx`** — no tocar
|
||||
- **Cualquier otro archivo** — sin cambios
|
||||
|
||||
**Campos `clientName` y `groupId` en el dominio:** todavía coexisten. En GlobalSettingsPanel, el campo "Cliente" se reemplaza por "Área". El `clientName` sigue en el proceso como dato histórico, solo deja de ser editable desde esta pantalla. Se eliminará del dominio en la migración de limpieza (futura Etapa de cleanup).
|
||||
|
||||
---
|
||||
|
||||
## Criterios de validación
|
||||
|
||||
### Funcionales (validación visual mandatoria del director antes del OK formal)
|
||||
|
||||
**GlobalSettingsPanel (workspace sidebar):**
|
||||
- [ ] El campo "Área" muestra un Select con las áreas de la org del proceso
|
||||
- [ ] La opción "Sin área" aparece primero en el dropdown
|
||||
- [ ] Seleccionar un área y guardar persiste `area_id` en el proceso (verificar en Supabase o en el Network tab)
|
||||
- [ ] Al recargar la página, el área seleccionada se muestra correctamente
|
||||
- [ ] Si el proceso no tiene `orgId` (edge case), el field muestra "Sin organización asignada" en lugar del dropdown
|
||||
- [ ] Si la org no tiene áreas, el dropdown muestra "No hay áreas en esta organización"
|
||||
- [ ] El campo "Cliente" ya no aparece en el panel
|
||||
|
||||
**WorkspacePage (topbar):**
|
||||
- [ ] Si el proceso tiene `areaName`, se muestra como texto de solo lectura bajo el nombre del proceso
|
||||
- [ ] Si no tiene `areaName`, la segunda línea no aparece (no muestra texto vacío ni placeholder)
|
||||
- [ ] Ya no hay edición inline de cliente en el topbar
|
||||
|
||||
**OrganizationDetailPage (admin):**
|
||||
- [ ] Existe un tercer tab "Áreas" en la página de detalle de organización
|
||||
- [ ] El tab lista las áreas con nombre y conteo de procesos
|
||||
- [ ] "Nueva área" abre un sheet con un input de nombre y botón Crear
|
||||
- [ ] Crear un área nueva aparece en la lista (sin recargar la página manualmente)
|
||||
- [ ] El botón de lápiz abre el sheet con el nombre actual pre-cargado para renombrar
|
||||
- [ ] El botón de papelera muestra confirmación inline con el conteo de procesos afectados
|
||||
- [ ] Confirmar delete elimina el área y la lista se actualiza
|
||||
- [ ] Cancelar delete cierra la confirmación sin eliminar
|
||||
|
||||
### Técnicos
|
||||
- [ ] `npm run build` sin errores TypeScript
|
||||
- [ ] `npm run test` sin regresiones (596 tests — esta etapa no agrega tests, pero no puede romper existentes)
|
||||
- [ ] No hay `any` introducido sin justificación
|
||||
|
||||
---
|
||||
|
||||
## Restricciones explícitas
|
||||
|
||||
- **NO eliminar** `clientName` ni `groupId` del dominio ni de los repositorios — sigue coexistiendo
|
||||
- **NO modificar** `process-store.ts` — el store ya maneja `areaId` vía partial updates
|
||||
- **NO modificar** `src/domain/types.ts` — ya tiene los campos necesarios desde Etapa 2
|
||||
- **NO modificar** `area-repo.ts` — no cambiar el repo existente
|
||||
- **NO agregar** nuevas dependencias — todo con el stack existente
|
||||
- **NO crear** archivos de test en esta etapa — el esfuerzo de test va en Etapa 6
|
||||
|
||||
---
|
||||
|
||||
## Archivos autorizados a modificar
|
||||
|
||||
```
|
||||
src/
|
||||
├── features/
|
||||
│ ├── workspace/
|
||||
│ │ ├── GlobalSettingsPanel.tsx ← Parte A: reemplazar campo Cliente → selector Área
|
||||
│ │ └── WorkspacePage.tsx ← Parte B: reemplazar inline edit clientName → display areaName
|
||||
│ └── admin/
|
||||
│ └── OrganizationDetailPage.tsx ← Parte C: agregar tab Áreas + CRUD
|
||||
```
|
||||
|
||||
Ningún otro archivo debe modificarse.
|
||||
|
||||
---
|
||||
|
||||
## Commits esperados
|
||||
|
||||
```
|
||||
feat(workspace): selector de área en GlobalSettingsPanel reemplaza campo libre "Cliente"
|
||||
feat(workspace): topbar muestra areaName en lugar de clientName inline-editable
|
||||
feat(admin): CRUD de áreas en OrganizationDetailPage — tab Áreas con crear/renombrar/eliminar
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Reporte esperado
|
||||
|
||||
1. Confirmar que el campo "Cliente" en `GlobalSettingsPanel` ya no existe y fue reemplazado por el selector de área
|
||||
2. Confirmar que el topbar de WorkspacePage ya no tiene edición inline de clientName
|
||||
3. Confirmar que `OrganizationDetailPage` tiene un tercer tab "Áreas" funcional
|
||||
4. Lista compacta de archivos modificados con resumen de cambios
|
||||
5. Resultado de `npm run build` y `npm run test` (con conteo de tests)
|
||||
6. **Solicitar validación visual del director** — describir el estado de la UI en los tres lugares para que el director valide en la app antes del OK formal
|
||||
343
sprints/sprint-8/ETAPA_5_PROMPT.md
Normal file
343
sprints/sprint-8/ETAPA_5_PROMPT.md
Normal file
@@ -0,0 +1,343 @@
|
||||
# Sprint 8 — Etapa 5: client_editor puede importar BPMN
|
||||
|
||||
> **Tipo:** DB + UI — habilitar flujo de importación para rol cliente editor
|
||||
> **Rama:** main
|
||||
> **Estimación:** 1.5 horas
|
||||
> **Dependencias:** Etapas 1–4 completadas ✅
|
||||
> (tabla `areas` y columna `org_id` en processes ya existen, `isClientRole` ya implementado en workspace)
|
||||
|
||||
---
|
||||
|
||||
## Objetivo
|
||||
|
||||
Habilitar que un usuario con rol `client_editor` pueda importar archivos BPMN propios. Actualmente la `ImportPage` redirige a todos los roles cliente a `/`. Con esta etapa, `client_editor` pasa a ser un ciudadano de primera clase del flujo de importación: puede subir un BPMN, el proceso se asigna automáticamente a su org, y puede trabajar desde el workspace como lo hace InQuality.
|
||||
|
||||
`client_viewer` **no** obtiene acceso de importación — eso no cambia.
|
||||
|
||||
---
|
||||
|
||||
## Contexto del código actual
|
||||
|
||||
Leer estos archivos antes de modificar:
|
||||
|
||||
- `src/features/import/ImportPage.tsx` — tiene dos guards que bloquean a client_editor:
|
||||
1. `useEffect` con `allowed = ['platform_admin', 'member']` → redirige a `/`
|
||||
2. `if (isClientRole) return null` antes del return final → nunca llega al JSX
|
||||
Además, el proceso se crea con `orgId: null`, lo que rompe las RLS.
|
||||
- `src/features/library/LibraryPage.tsx` — el botón "Importar BPMN" del header está envuelto en `{!isClientRole && ...}` (líneas ~841-860). El empty state de la vista cliente muestra "Contactá a tu administrador" sin botón de importar para nadie.
|
||||
- `supabase/migrations/015_client_roles_and_rls.sql` — la política `insert_processes` permite solo `platform_admin` y `member`. La línea de comentario lo dice explícitamente: `-- client_editor y client_viewer NO pueden crear procesos nuevos`.
|
||||
|
||||
---
|
||||
|
||||
## Parte 1: Migration 018
|
||||
|
||||
Crear `supabase/migrations/018_client_editor_insert.sql`.
|
||||
|
||||
El único cambio de esta migración: extender la política `insert_processes` para incluir a `client_editor` cuando inserta en su propia org.
|
||||
|
||||
```sql
|
||||
-- Permite que client_editor inserte procesos en su propia org
|
||||
-- Los demás roles mantienen sus permisos actuales
|
||||
|
||||
DROP POLICY IF EXISTS "insert_processes" ON public.processes;
|
||||
|
||||
CREATE POLICY "insert_processes" ON public.processes
|
||||
FOR INSERT WITH CHECK (
|
||||
public.is_platform_admin()
|
||||
OR public.current_platform_role() = 'member'
|
||||
OR (
|
||||
public.current_platform_role() = 'client_editor'
|
||||
AND org_id = public.current_org()
|
||||
)
|
||||
-- client_viewer y guest siguen sin poder crear procesos
|
||||
);
|
||||
```
|
||||
|
||||
**Verificar que las políticas de actividades y gateways ya permiten a client_editor:**
|
||||
Buscar en `015_client_roles_and_rls.sql`:
|
||||
- `activities_insert`: usa `NOT IN ('client_viewer', 'guest')` → client_editor puede ✅
|
||||
- `gateways_write`: usa `NOT IN ('client_viewer', 'guest')` → client_editor puede ✅
|
||||
|
||||
Si alguna de esas dos NO incluye a client_editor, corregir en esta misma migración. Verificar antes de cerrar el archivo.
|
||||
|
||||
### Aplicar la migración
|
||||
|
||||
Aplicar en Supabase y verificar:
|
||||
|
||||
```sql
|
||||
-- Verificar que la nueva política existe
|
||||
SELECT policyname, cmd, qual
|
||||
FROM pg_policies
|
||||
WHERE schemaname = 'public' AND tablename = 'processes';
|
||||
-- Esperado: ver "insert_processes" con la nueva definición que incluye client_editor
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Parte 2: `ImportPage.tsx`
|
||||
|
||||
### 2A — Eliminar los dos guards que bloquean a client_editor
|
||||
|
||||
**Guard 1 — cambiar la lista de roles permitidos en el `useEffect`:**
|
||||
|
||||
```typescript
|
||||
// Antes:
|
||||
const allowed = ['platform_admin', 'member']
|
||||
if (!allowed.includes(user.platformRole)) {
|
||||
void navigate({ to: '/' })
|
||||
}
|
||||
|
||||
// Después:
|
||||
const allowed = ['platform_admin', 'member', 'client_editor']
|
||||
if (!allowed.includes(user.platformRole)) {
|
||||
void navigate({ to: '/' })
|
||||
}
|
||||
```
|
||||
|
||||
**Guard 2 — reemplazar el bloqueo total por uno solo para client_viewer:**
|
||||
|
||||
```typescript
|
||||
// Antes (bloquea a todos los client roles):
|
||||
const isClientRole = user?.platformRole === 'client_editor' || user?.platformRole === 'client_viewer'
|
||||
if (isClientRole) return null
|
||||
|
||||
// Después (solo bloquea client_viewer):
|
||||
if (user?.platformRole === 'client_viewer') return null
|
||||
```
|
||||
|
||||
### 2B — Asignar `orgId` del usuario en el proceso creado
|
||||
|
||||
Al construir el objeto `process` dentro de `processFile`, el campo `orgId` actualmente es `null`. Para que la RLS de `insert_processes` valide `org_id = current_org()`, el proceso insertado debe tener el `org_id` del usuario.
|
||||
|
||||
```typescript
|
||||
// Antes:
|
||||
const process: Process = {
|
||||
// ...
|
||||
orgId: null,
|
||||
// ...
|
||||
}
|
||||
|
||||
// Después:
|
||||
const process: Process = {
|
||||
// ...
|
||||
orgId: user!.orgId, // ← org_id del usuario autenticado; null solo si no tiene org (no debería pasar para client_editor)
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
Verificar que `user.orgId` está disponible en el tipo `User` del AuthContext (debería estar — se pobló en Sprint 7).
|
||||
|
||||
### 2C — Ocultar los procesos de ejemplo para client_editor
|
||||
|
||||
Los "procesos de ejemplo" (`SAMPLE_PROCESSES`) son demostraciones internas de InQuality. Un client_editor no debería importar datos de prueba de InQuality en su propio espacio.
|
||||
|
||||
En el JSX, la sección de ejemplos está bajo el drop zone. Envolverla en una condición:
|
||||
|
||||
```tsx
|
||||
// Antes: siempre visible
|
||||
<div className="mt-8 w-full max-w-lg">
|
||||
<p ...>O cargá un proceso de ejemplo con un click</p>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{SAMPLE_PROCESSES.map(...)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
// Después: solo visible para InQuality (platform_admin y member)
|
||||
{user?.platformRole !== 'client_editor' && (
|
||||
<div className="mt-8 w-full max-w-lg">
|
||||
<p ...>O cargá un proceso de ejemplo con un click</p>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{SAMPLE_PROCESSES.map(...)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
```
|
||||
|
||||
### 2D — Corregir texto de marca en ImportPage
|
||||
|
||||
El header tiene texto desactualizado que viola las reglas de marca del producto:
|
||||
|
||||
```tsx
|
||||
// Antes (incorrecto — nombre viejo del producto):
|
||||
<div className="inline-flex items-center gap-2 ...">
|
||||
<FlaskConical className="h-3.5 w-3.5" />
|
||||
Process Cost Platform · MVP Fase 0
|
||||
</div>
|
||||
|
||||
// Después:
|
||||
<div className="inline-flex items-center gap-2 ...">
|
||||
<FlaskConical className="h-3.5 w-3.5" />
|
||||
InQ ROI
|
||||
</div>
|
||||
```
|
||||
|
||||
El tagline del final de la página también está desactualizado:
|
||||
|
||||
```tsx
|
||||
// Antes:
|
||||
<p className="mt-8 text-xs text-slate-300">
|
||||
100% en tu navegador · Sin backend · Sin registro · Datos guardados localmente
|
||||
</p>
|
||||
|
||||
// Después: eliminar este párrafo (el producto ya tiene backend y registro)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Parte 3: `LibraryPage.tsx`
|
||||
|
||||
### 3A — Botón "Importar BPMN" en el header visible para client_editor
|
||||
|
||||
El botón en el header de LibraryPage (líneas ~841-860) está condicionado con `!isClientRole`, lo que lo oculta para todos los roles cliente. Cambiar para mostrarlo a `client_editor`:
|
||||
|
||||
```tsx
|
||||
// Antes:
|
||||
{!isClientRole && (
|
||||
<button
|
||||
onClick={handleImport}
|
||||
className="..."
|
||||
style={{ background: '#F59845' }}
|
||||
>
|
||||
<Upload className="h-4 w-4" />
|
||||
Importar BPMN
|
||||
</button>
|
||||
)}
|
||||
|
||||
// Después:
|
||||
{user?.platformRole !== 'client_viewer' && (
|
||||
<button
|
||||
onClick={handleImport}
|
||||
className="..."
|
||||
style={{ background: '#F59845' }}
|
||||
>
|
||||
<Upload className="h-4 w-4" />
|
||||
Importar BPMN
|
||||
</button>
|
||||
)}
|
||||
```
|
||||
|
||||
### 3B — Empty state diferenciado para client_editor vs client_viewer
|
||||
|
||||
En `HomeView`, el empty state para roles cliente (cuando `groups.length === 0 && processes.length === 0`) actualmente muestra "Contactá a tu administrador" para todos. Diferenciarlo:
|
||||
|
||||
```tsx
|
||||
// Antes (igual para todos los client roles):
|
||||
if (isClientRole) {
|
||||
return (
|
||||
<div ...>
|
||||
<Building2 ... />
|
||||
<p>No tenés procesos asignados aún</p>
|
||||
<p>Contactá a tu administrador para que te asigne acceso</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Después (diferenciado):
|
||||
if (isClientRole) {
|
||||
if (user?.platformRole === 'client_editor') {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-24 gap-4">
|
||||
<Building2 className="h-16 w-16 text-muted-foreground/30" />
|
||||
<p className="text-base font-medium">Tu biblioteca está vacía</p>
|
||||
<p className="text-[13px] text-muted-foreground">
|
||||
Importá tu primer BPMN para empezar a cuantificar costos
|
||||
</p>
|
||||
<button
|
||||
onClick={() => onImport()}
|
||||
className="flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium text-white transition-opacity hover:opacity-90"
|
||||
style={{ background: '#F59845' }}
|
||||
>
|
||||
<Upload className="h-4 w-4" />
|
||||
Importar BPMN
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
// client_viewer: mantener mensaje existente
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-24 gap-4">
|
||||
<Building2 className="h-16 w-16 text-muted-foreground/30" />
|
||||
<p className="text-base font-medium">No tenés procesos asignados aún</p>
|
||||
<p className="text-[13px] text-muted-foreground">Contactá a tu administrador para que te asigne acceso</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
`user` está disponible en el scope de `HomeView` como prop (o via `useAuth()` — verificar cuál aplica).
|
||||
|
||||
---
|
||||
|
||||
## Lo que NO cambia en esta etapa
|
||||
|
||||
- **`process-repo.ts`** — no tocar. `updateEditable()` no necesita `bpmn_xml` para este flujo.
|
||||
- **`process-store.ts`** — no tocar.
|
||||
- **`WorkspacePage.tsx`** — sin cambios. client_editor ya tiene acceso al workspace desde Sprint 7 (la RLS de `update_processes` ya lo cubre).
|
||||
- **`GlobalSettingsPanel.tsx`** — sin cambios. El selector de área ya funciona para client_editor desde Etapa 4.
|
||||
- **Rol `client_viewer`** — sigue sin acceso a importación en todo momento.
|
||||
|
||||
---
|
||||
|
||||
## Criterios de validación
|
||||
|
||||
### Técnicos
|
||||
- [ ] Migración 018 creada y aplicada en Supabase
|
||||
- [ ] `insert_processes` en la DB incluye la cláusula de client_editor (verificar con `pg_policies`)
|
||||
- [ ] `npm run build` sin errores TypeScript
|
||||
- [ ] `npm run test` sin regresiones (596 tests)
|
||||
|
||||
### Funcionales (validación visual mandatoria del director)
|
||||
- [ ] Loguear como `client_editor` → `/library` → botón "Importar BPMN" visible en el header
|
||||
- [ ] Hacer click en "Importar BPMN" → lleva a `/import` (no redirige a `/`)
|
||||
- [ ] `/import` muestra el drop zone; la sección de "procesos de ejemplo" NO aparece
|
||||
- [ ] Subir un archivo BPMN real → proceso creado → workspace abre correctamente
|
||||
- [ ] En Supabase: verificar que el proceso creado tiene `org_id` = org del client_editor (no null)
|
||||
- [ ] Loguear como `client_viewer` → `/library` → botón "Importar BPMN" NO visible
|
||||
- [ ] Si client_viewer navega manualmente a `/import` → redirige a `/`
|
||||
- [ ] La biblioteca vacía de client_editor muestra botón de importar; la de client_viewer muestra "Contactá a tu administrador"
|
||||
|
||||
---
|
||||
|
||||
## Restricciones explícitas
|
||||
|
||||
- **NO** tocar políticas RLS de tablas distintas a `processes` (activities y gateways ya permiten client_editor)
|
||||
- **NO** crear procesos de muestra en la DB para client_editor — el flujo es importar BPMNs reales
|
||||
- **NO** modificar el workspace ni el reporte — client_editor ya puede usarlos
|
||||
- **NO** agregar tests en esta etapa
|
||||
|
||||
---
|
||||
|
||||
## Archivos autorizados a modificar/crear
|
||||
|
||||
```
|
||||
supabase/migrations/
|
||||
└── 018_client_editor_insert.sql ← crear y aplicar
|
||||
|
||||
src/features/
|
||||
├── import/
|
||||
│ └── ImportPage.tsx ← guards + orgId + ejemplos + texto marca
|
||||
└── library/
|
||||
└── LibraryPage.tsx ← botón header + empty state diferenciado
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Commits esperados
|
||||
|
||||
```
|
||||
feat(db): migración 018 — INSERT policy para client_editor en processes
|
||||
feat(import): client_editor puede importar BPMN; orgId asignado automáticamente
|
||||
feat(library): botón Importar BPMN visible para client_editor; empty state diferenciado
|
||||
fix(import): corregir texto de marca — "Process Cost Platform" → "InQ ROI"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Reporte esperado
|
||||
|
||||
1. Contenido exacto de `018_client_editor_insert.sql`
|
||||
2. Resultado de la query de verificación en `pg_policies` para `insert_processes`
|
||||
3. Lista de cambios en `ImportPage.tsx` (resumen compacto)
|
||||
4. Lista de cambios en `LibraryPage.tsx` (resumen compacto)
|
||||
5. `npm run build` y `npm run test` (con conteo)
|
||||
6. **Solicitar validación visual del director** — describir el estado de la UI para client_editor antes del OK formal
|
||||
30
src/components/RequireRole.tsx
Normal file
30
src/components/RequireRole.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
import { useAuth } from '@/auth/AuthContext'
|
||||
|
||||
export function RequireRole({
|
||||
roles,
|
||||
redirectTo = '/',
|
||||
children,
|
||||
}: {
|
||||
roles: string[]
|
||||
redirectTo?: '/' | '/library'
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const { user, isProfileLoaded } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
|
||||
useEffect(() => {
|
||||
if (!isProfileLoaded) return
|
||||
if (!user || !roles.includes(user.platformRole)) {
|
||||
void navigate({ to: redirectTo })
|
||||
}
|
||||
// roles y redirectTo son constantes en cada call site — excluidos intencionalmente
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [user?.platformRole, isProfileLoaded, navigate])
|
||||
|
||||
if (!isProfileLoaded || !user) return null
|
||||
if (!roles.includes(user.platformRole)) return null
|
||||
|
||||
return <>{children}</>
|
||||
}
|
||||
@@ -1,33 +1,13 @@
|
||||
import { useEffect } from 'react'
|
||||
import { Outlet, Link, useNavigate, useRouterState } from '@tanstack/react-router'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { useAuth } from '@/auth/AuthContext'
|
||||
import { Outlet, Link, useRouterState } from '@tanstack/react-router'
|
||||
import { AppHeader } from '@/components/AppHeader'
|
||||
import { RequireRole } from '@/components/RequireRole'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export function AdminLayout() {
|
||||
const { user, loading } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
const pathname = useRouterState({ select: (s) => s.location.pathname })
|
||||
|
||||
useEffect(() => {
|
||||
if (loading) return
|
||||
if (!user || user.platformRole !== 'platform_admin') {
|
||||
void navigate({ to: '/' })
|
||||
}
|
||||
}, [user, loading, navigate])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<Loader2 className="h-8 w-8 animate-spin" style={{ color: '#F59845' }} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!user || user.platformRole !== 'platform_admin') return null
|
||||
|
||||
return (
|
||||
<RequireRole roles={['platform_admin']}>
|
||||
<div className="min-h-screen flex flex-col bg-slate-50">
|
||||
<AppHeader />
|
||||
<div className="flex-1 max-w-6xl mx-auto w-full px-6 py-8">
|
||||
@@ -42,6 +22,7 @@ export function AdminLayout() {
|
||||
<Outlet />
|
||||
</div>
|
||||
</div>
|
||||
</RequireRole>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useParams, useNavigate, Link } from '@tanstack/react-router'
|
||||
import { ArrowLeft, FileText, Users, FolderX, UserX, GitBranch } from 'lucide-react'
|
||||
import { ArrowLeft, FileText, Users, FolderX, UserX, GitBranch, FolderOpen, Plus, Pencil, Trash2 } from 'lucide-react'
|
||||
import { supabase } from '@/lib/supabase'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
@@ -20,14 +20,25 @@ import {
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { RoleBadge } from './RoleBadge'
|
||||
import { supabaseAreaRepo } from '@/persistence/supabase/area-repo'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
|
||||
interface OrgProcess {
|
||||
id: string
|
||||
name: string
|
||||
client: string | null
|
||||
area_id: string | null
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
interface AreaWithCount {
|
||||
id: string
|
||||
name: string
|
||||
description: string | null
|
||||
processCount: number
|
||||
}
|
||||
|
||||
interface OrgUser {
|
||||
id: string
|
||||
name: string | null
|
||||
@@ -66,16 +77,53 @@ export function OrganizationDetailPage() {
|
||||
const [assigning, setAssigning] = useState(false)
|
||||
const [assignError, setAssignError] = useState<string | null>(null)
|
||||
|
||||
// Áreas de la org
|
||||
const [areas, setAreas] = useState<AreaWithCount[]>([])
|
||||
const [areasLoading, setAreasLoading] = useState(false)
|
||||
|
||||
// Sheet crear/editar área
|
||||
const [areaSheetOpen, setAreaSheetOpen] = useState(false)
|
||||
const [editingAreaId, setEditingAreaId] = useState<string | null>(null)
|
||||
const [areaNameInput, setAreaNameInput] = useState('')
|
||||
const [savingArea, setSavingArea] = useState(false)
|
||||
const [areaFormError, setAreaFormError] = useState<string | null>(null)
|
||||
|
||||
// Confirmación de delete
|
||||
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null)
|
||||
const [deletingAreaId, setDeletingAreaId] = useState<string | null>(null)
|
||||
|
||||
const fetchDetail = async () => {
|
||||
const [orgRes, procRes, userRes] = await Promise.all([
|
||||
supabase.from('organizations').select('name').eq('id', orgId).single(),
|
||||
supabase.from('processes').select('id, name, client, updated_at').eq('org_id', orgId).order('updated_at', { ascending: false }),
|
||||
supabase
|
||||
.from('processes')
|
||||
.select('id, name, client, area_id, updated_at')
|
||||
.eq('org_id', orgId)
|
||||
.order('updated_at', { ascending: false }),
|
||||
supabase.from('users').select('id, name, company, platform_role, avatar_url').eq('org_id', orgId).order('name'),
|
||||
])
|
||||
if (orgRes.data) setOrgName((orgRes.data as { name: string }).name)
|
||||
setProcesses((procRes.data as OrgProcess[] | null) ?? [])
|
||||
const loadedProcesses = (procRes.data as OrgProcess[] | null) ?? []
|
||||
setProcesses(loadedProcesses)
|
||||
setUsers((userRes.data as OrgUser[] | null) ?? [])
|
||||
setLoading(false)
|
||||
|
||||
// Cargar áreas y calcular conteo de procesos
|
||||
setAreasLoading(true)
|
||||
try {
|
||||
const rawAreas = await supabaseAreaRepo.getByOrg(orgId)
|
||||
const withCount: AreaWithCount[] = rawAreas.map((a) => ({
|
||||
id: a.id,
|
||||
name: a.name,
|
||||
description: a.description,
|
||||
processCount: loadedProcesses.filter((p) => p.area_id === a.id).length,
|
||||
}))
|
||||
setAreas(withCount)
|
||||
} catch {
|
||||
setAreas([])
|
||||
} finally {
|
||||
setAreasLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => { void fetchDetail() }, [orgId])
|
||||
@@ -114,6 +162,53 @@ export function OrganizationDetailPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const openCreateArea = () => {
|
||||
setEditingAreaId(null)
|
||||
setAreaNameInput('')
|
||||
setAreaFormError(null)
|
||||
setAreaSheetOpen(true)
|
||||
}
|
||||
|
||||
const openEditArea = (area: AreaWithCount) => {
|
||||
setEditingAreaId(area.id)
|
||||
setAreaNameInput(area.name)
|
||||
setAreaFormError(null)
|
||||
setAreaSheetOpen(true)
|
||||
}
|
||||
|
||||
const handleSaveArea = async () => {
|
||||
const name = areaNameInput.trim()
|
||||
if (!name) { setAreaFormError('El nombre del área es requerido'); return }
|
||||
setSavingArea(true)
|
||||
setAreaFormError(null)
|
||||
try {
|
||||
if (editingAreaId) {
|
||||
await supabaseAreaRepo.update(editingAreaId, { name })
|
||||
} else {
|
||||
await supabaseAreaRepo.create({ orgId, name })
|
||||
}
|
||||
setAreaSheetOpen(false)
|
||||
void fetchDetail()
|
||||
} catch (e) {
|
||||
setAreaFormError(e instanceof Error ? e.message : 'Error al guardar el área')
|
||||
} finally {
|
||||
setSavingArea(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteArea = async (areaId: string) => {
|
||||
setDeletingAreaId(areaId)
|
||||
try {
|
||||
await supabaseAreaRepo.delete(areaId)
|
||||
setDeleteConfirmId(null)
|
||||
void fetchDetail()
|
||||
} catch (e) {
|
||||
console.error('Error eliminando área:', e)
|
||||
} finally {
|
||||
setDeletingAreaId(null)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
@@ -148,6 +243,10 @@ export function OrganizationDetailPage() {
|
||||
<Users className="h-4 w-4 mr-1.5" />
|
||||
Usuarios ({users.length})
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="areas">
|
||||
<FolderOpen className="h-4 w-4 mr-1.5" />
|
||||
Áreas ({areas.length})
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* ─── Tab: Procesos ─────────────────────────────────────────────── */}
|
||||
@@ -240,6 +339,105 @@ export function OrganizationDetailPage() {
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
{/* ─── Tab: Áreas ──────────────────────────────────────────────── */}
|
||||
<TabsContent value="areas">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<p className="text-sm text-slate-500">
|
||||
{areasLoading
|
||||
? 'Cargando áreas…'
|
||||
: areas.length === 0
|
||||
? 'No hay áreas en esta organización.'
|
||||
: `${areas.length} área${areas.length !== 1 ? 's' : ''}.`}
|
||||
</p>
|
||||
<Button size="sm" variant="outline" onClick={openCreateArea}>
|
||||
<Plus className="h-4 w-4 mr-1.5" />
|
||||
Nueva área
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{areasLoading ? (
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-12 w-full" />
|
||||
<Skeleton className="h-12 w-full" />
|
||||
</div>
|
||||
) : areas.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={<FolderOpen className="h-10 w-10 text-slate-300" />}
|
||||
message="Las áreas agrupan procesos dentro de una organización (ej: Operaciones, RR.HH., Finanzas)."
|
||||
/>
|
||||
) : (
|
||||
<div className="rounded-lg border border-slate-200 overflow-hidden bg-white">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-slate-50 border-b border-slate-200">
|
||||
<tr>
|
||||
<th className="text-left py-2.5 px-4 font-medium text-slate-600">Área</th>
|
||||
<th className="text-left py-2.5 px-4 font-medium text-slate-600">Procesos</th>
|
||||
<th className="py-2.5 px-4" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{areas.map((area) => (
|
||||
<tr key={area.id} className="border-b border-slate-100 last:border-0 hover:bg-slate-50/50">
|
||||
<td className="py-3 px-4 font-medium text-slate-900">{area.name}</td>
|
||||
<td className="py-3 px-4 text-slate-500">
|
||||
{area.processCount} {area.processCount === 1 ? 'proceso' : 'procesos'}
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
{deleteConfirmId === area.id ? (
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<span className="text-xs text-slate-500">
|
||||
{area.processCount > 0
|
||||
? `${area.processCount} proceso${area.processCount !== 1 ? 's' : ''} quedarán sin área.`
|
||||
: '¿Eliminar esta área?'}
|
||||
</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
className="h-7 text-xs px-2"
|
||||
disabled={deletingAreaId === area.id}
|
||||
onClick={() => handleDeleteArea(area.id)}
|
||||
>
|
||||
{deletingAreaId === area.id ? 'Eliminando…' : 'Confirmar'}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 text-xs px-2"
|
||||
onClick={() => setDeleteConfirmId(null)}
|
||||
>
|
||||
Cancelar
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 w-7 p-0 text-slate-400 hover:text-slate-700"
|
||||
onClick={() => openEditArea(area)}
|
||||
title="Renombrar área"
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 w-7 p-0 text-slate-400 hover:text-red-600"
|
||||
onClick={() => setDeleteConfirmId(area.id)}
|
||||
title="Eliminar área"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
{/* ─── Sheet: Asignar proceso ─────────────────────────────────────── */}
|
||||
@@ -277,6 +475,40 @@ export function OrganizationDetailPage() {
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
{/* ─── Sheet: Crear/editar área ────────────────────────────────── */}
|
||||
<Sheet open={areaSheetOpen} onOpenChange={setAreaSheetOpen}>
|
||||
<SheetContent>
|
||||
<SheetHeader>
|
||||
<SheetTitle>
|
||||
{editingAreaId ? 'Renombrar área' : `Nueva área en ${orgName}`}
|
||||
</SheetTitle>
|
||||
</SheetHeader>
|
||||
<div className="mt-5 flex flex-col gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="area-name" className="text-sm font-medium">
|
||||
Nombre del área
|
||||
</Label>
|
||||
<Input
|
||||
id="area-name"
|
||||
value={areaNameInput}
|
||||
onChange={(e) => { setAreaNameInput(e.target.value); setAreaFormError(null) }}
|
||||
placeholder="ej. Operaciones, RR.HH., Finanzas"
|
||||
className="h-9"
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') void handleSaveArea() }}
|
||||
autoFocus
|
||||
/>
|
||||
{areaFormError && (
|
||||
<p className="text-xs text-red-600">{areaFormError}</p>
|
||||
)}
|
||||
</div>
|
||||
<SheetFooter>
|
||||
<Button onClick={handleSaveArea} disabled={savingArea || !areaNameInput.trim()}>
|
||||
{savingArea ? 'Guardando…' : editingAreaId ? 'Guardar cambios' : 'Crear área'}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
interface OrgRow {
|
||||
id: string
|
||||
name: string
|
||||
is_provider: boolean
|
||||
created_at: string
|
||||
processes: [{ count: number }]
|
||||
users: [{ count: number }]
|
||||
@@ -41,10 +42,16 @@ export function OrganizationsPage() {
|
||||
const fetchOrgs = async () => {
|
||||
const { data } = await supabase
|
||||
.from('organizations')
|
||||
.select('id, name, created_at, processes:processes(count), users:users(count)')
|
||||
.eq('is_provider', false)
|
||||
.select('id, name, is_provider, created_at, processes:processes(count), users:users(count)')
|
||||
.order('name')
|
||||
setOrgs((data as OrgRow[] | null) ?? [])
|
||||
|
||||
const sorted = ((data as OrgRow[] | null) ?? []).sort((a, b) => {
|
||||
if (a.is_provider && !b.is_provider) return -1
|
||||
if (!a.is_provider && b.is_provider) return 1
|
||||
return a.name.localeCompare(b.name)
|
||||
})
|
||||
|
||||
setOrgs(sorted)
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
@@ -84,7 +91,7 @@ export function OrganizationsPage() {
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center justify-between mb-5">
|
||||
<h2 className="text-lg font-semibold text-slate-900">Organizaciones clientes</h2>
|
||||
<h2 className="text-lg font-semibold text-slate-900">Organizaciones</h2>
|
||||
<Button size="sm" onClick={openCreate}>
|
||||
<Plus className="h-4 w-4 mr-1.5" />
|
||||
Nueva organización
|
||||
@@ -113,7 +120,17 @@ export function OrganizationsPage() {
|
||||
<tbody>
|
||||
{orgs.map((org) => (
|
||||
<tr key={org.id} className="border-b border-slate-100 last:border-0 hover:bg-slate-50/50 transition-colors">
|
||||
<td className="py-3 px-4 font-medium text-slate-900">{org.name}</td>
|
||||
<td className="py-3 px-4 font-medium text-slate-900">
|
||||
<div className="flex items-center gap-2">
|
||||
{org.name}
|
||||
{org.is_provider && (
|
||||
<span className="text-[10px] font-semibold px-1.5 py-0.5 rounded-full border"
|
||||
style={{ color: '#F59845', background: '#fff8f0', borderColor: '#fcd9aa' }}>
|
||||
InQuality
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-3 px-4 text-right text-slate-600 tabular-nums">
|
||||
{org.processes[0]?.count ?? 0}
|
||||
</td>
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
/**
|
||||
* Limitaciones conocidas documentadas:
|
||||
* 1. email no está en public.users (vive en auth.users, requiere service_role) → solo se muestra name.
|
||||
* 2. "Eliminar" llama revoke-user que baja platform_role a 'guest'. Eliminación real requiere Edge Function futura.
|
||||
* 3. "Reactivar" restaura siempre a 'client_editor' — no se guarda el rol previo a la suspensión.
|
||||
* Limitaciones conocidas:
|
||||
* - "Reactivar" restaura siempre a 'client_editor' — no se guarda el rol previo a la suspensión.
|
||||
*/
|
||||
import { useEffect, useState } from 'react'
|
||||
import * as DialogPrimitive from '@radix-ui/react-dialog'
|
||||
import { UserX, UserPlus, MoreVertical } from 'lucide-react'
|
||||
import { UserX, UserPlus, MoreVertical, Trash2 } from 'lucide-react'
|
||||
import { supabase } from '@/lib/supabase'
|
||||
import { useAuth } from '@/auth/AuthContext'
|
||||
import { Button } from '@/components/ui/button'
|
||||
@@ -34,6 +32,7 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { RoleBadge } from './RoleBadge'
|
||||
|
||||
interface AdminUser {
|
||||
@@ -44,6 +43,7 @@ interface AdminUser {
|
||||
org_id: string | null
|
||||
avatar_url: string | null
|
||||
organization: { name: string; is_provider: boolean } | null
|
||||
email?: string | null
|
||||
}
|
||||
|
||||
interface OrgOption {
|
||||
@@ -116,6 +116,7 @@ const CHANGE_ROLES = ['platform_admin', 'member', 'client_editor', 'client_viewe
|
||||
|
||||
export function UsersPage() {
|
||||
const { user: adminUser } = useAuth()
|
||||
const { toast } = useToast()
|
||||
|
||||
const [users, setUsers] = useState<AdminUser[]>([])
|
||||
const [orgs, setOrgs] = useState<OrgOption[]>([])
|
||||
@@ -155,8 +156,25 @@ export function UsersPage() {
|
||||
const finalQuery = filterOrgId !== 'all'
|
||||
? query.eq('org_id', filterOrgId)
|
||||
: query
|
||||
const { data } = await finalQuery
|
||||
setUsers((data as AdminUser[] | null) ?? [])
|
||||
|
||||
const [{ data }, { data: emailData }] = await Promise.all([
|
||||
finalQuery,
|
||||
supabase.functions.invoke('list-users'),
|
||||
])
|
||||
|
||||
const emailMap = new Map<string, string | null>()
|
||||
if (emailData) {
|
||||
for (const u of (emailData as Array<{ id: string; email: string | null }>) ?? []) {
|
||||
emailMap.set(u.id, u.email ?? null)
|
||||
}
|
||||
}
|
||||
|
||||
const usersWithEmail = ((data as AdminUser[] | null) ?? []).map((u) => ({
|
||||
...u,
|
||||
email: emailMap.get(u.id) ?? null,
|
||||
}))
|
||||
|
||||
setUsers(usersWithEmail)
|
||||
setLoadingUsers(false)
|
||||
}
|
||||
|
||||
@@ -260,16 +278,24 @@ export function UsersPage() {
|
||||
void fetchUsers()
|
||||
}
|
||||
|
||||
// ── Delete (también usa revoke-user — baja a guest, eliminación real futura) ─
|
||||
// ── Hard delete vía Edge Function delete-user ─────────────────────────────
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!deleteTarget) return
|
||||
setDeleting(true)
|
||||
const { error } = await supabase.functions.invoke('revoke-user', {
|
||||
const { error } = await supabase.functions.invoke('delete-user', {
|
||||
body: { userId: deleteTarget.id },
|
||||
})
|
||||
if (error) {
|
||||
console.error('Error al eliminar usuario:', error)
|
||||
toast({
|
||||
title: 'Error al eliminar usuario',
|
||||
description: typeof error === 'object' && 'message' in error
|
||||
? String(error.message)
|
||||
: 'Ocurrió un error inesperado.',
|
||||
variant: 'destructive',
|
||||
})
|
||||
} else {
|
||||
toast({ title: 'Usuario eliminado permanentemente' })
|
||||
}
|
||||
setDeleteTarget(null)
|
||||
setDeleting(false)
|
||||
@@ -334,6 +360,7 @@ export function UsersPage() {
|
||||
<thead className="bg-slate-50 border-b border-slate-200">
|
||||
<tr>
|
||||
<th className="text-left py-2.5 px-4 font-medium text-slate-600">Nombre</th>
|
||||
<th className="text-left py-2.5 px-4 font-medium text-slate-600">Email</th>
|
||||
<th className="text-left py-2.5 px-4 font-medium text-slate-600">Rol</th>
|
||||
<th className="text-left py-2.5 px-4 font-medium text-slate-600">Organización</th>
|
||||
<th className="py-2.5 px-4" />
|
||||
@@ -363,6 +390,9 @@ export function UsersPage() {
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-3 px-4 text-slate-500 text-xs font-mono">
|
||||
{u.email ?? '—'}
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<RoleBadge role={u.platform_role} />
|
||||
</td>
|
||||
@@ -403,7 +433,8 @@ export function UsersPage() {
|
||||
className="text-destructive focus:text-destructive"
|
||||
onClick={() => setDeleteTarget(u)}
|
||||
>
|
||||
Eliminar
|
||||
<Trash2 className="h-3.5 w-3.5 mr-2" />
|
||||
Eliminar permanentemente
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
@@ -537,13 +568,13 @@ export function UsersPage() {
|
||||
destructive
|
||||
/>
|
||||
|
||||
{/* ─── ConfirmDialog: Eliminar ─────────────────────────────────────── */}
|
||||
{/* ─── ConfirmDialog: Eliminar permanentemente ────────────────────── */}
|
||||
<ConfirmDialog
|
||||
open={!!deleteTarget}
|
||||
onOpenChange={(open) => !open && setDeleteTarget(null)}
|
||||
title={`¿Eliminar a ${deleteTarget?.name ?? 'este usuario'}?`}
|
||||
description="El usuario quedará suspendido permanentemente. (Nota: la eliminación real de la cuenta estará disponible en una versión futura.)"
|
||||
confirmLabel="Eliminar"
|
||||
title={`¿Eliminar permanentemente a ${deleteTarget?.name ?? 'este usuario'}?`}
|
||||
description="Esta acción no puede deshacerse. El usuario perderá acceso inmediatamente y sus datos de autenticación serán eliminados de forma permanente."
|
||||
confirmLabel="Eliminar permanentemente"
|
||||
onConfirm={handleDelete}
|
||||
loading={deleting}
|
||||
destructive
|
||||
@@ -580,6 +611,7 @@ function TableSkeleton({ rows }: { rows: number }) {
|
||||
{Array.from({ length: rows }).map((_, i) => (
|
||||
<div key={i} className="flex gap-4 px-4 py-3 bg-white items-center">
|
||||
<Skeleton className="h-4 flex-1" />
|
||||
<Skeleton className="h-4 w-40" />
|
||||
<Skeleton className="h-5 w-20 rounded" />
|
||||
<Skeleton className="h-4 w-28" />
|
||||
<Skeleton className="h-6 w-6 rounded" />
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useNavigate, useSearch } from '@tanstack/react-router'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { UploadCloud, FileText, Loader2, FlaskConical } from 'lucide-react'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
@@ -13,6 +13,7 @@ import { useToast } from '@/components/ui/use-toast'
|
||||
import { useAuth } from '@/auth/AuthContext'
|
||||
import { AppFooter } from '@/components/AppFooter'
|
||||
import { AppHeader } from '@/components/AppHeader'
|
||||
import { RequireRole } from '@/components/RequireRole'
|
||||
|
||||
const SAMPLE_PROCESSES = [
|
||||
{
|
||||
@@ -43,15 +44,6 @@ export function ImportPage() {
|
||||
const [isProcessing, setIsProcessing] = useState(false)
|
||||
const [loadingSampleId, setLoadingSampleId] = useState<string | null>(null)
|
||||
|
||||
// Solo platform_admin y member pueden importar BPMN
|
||||
useEffect(() => {
|
||||
if (!user) return
|
||||
const allowed = ['platform_admin', 'member']
|
||||
if (!allowed.includes(user.platformRole)) {
|
||||
void navigate({ to: '/' })
|
||||
}
|
||||
}, [user, navigate])
|
||||
|
||||
// groupId viene de la URL cuando el usuario importa desde dentro de un grupo
|
||||
const search = useSearch({ from: '/import' })
|
||||
const targetGroupId = (search as { groupId?: string }).groupId ?? null
|
||||
@@ -91,7 +83,7 @@ export function ImportPage() {
|
||||
tags: [],
|
||||
ownerId: user!.id,
|
||||
updatedBy: user!.id,
|
||||
orgId: null,
|
||||
orgId: user!.orgId ?? null,
|
||||
areaId: null,
|
||||
}
|
||||
|
||||
@@ -167,7 +159,7 @@ export function ImportPage() {
|
||||
console.error(err)
|
||||
}
|
||||
}
|
||||
}, [navigate, toast])
|
||||
}, [navigate, toast, user])
|
||||
|
||||
const onDrop = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
@@ -201,10 +193,8 @@ export function ImportPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const isClientRole = user?.platformRole === 'client_editor' || user?.platformRole === 'client_viewer'
|
||||
if (isClientRole) return null
|
||||
|
||||
return (
|
||||
<RequireRole roles={['platform_admin', 'member', 'client_editor']}>
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-slate-100 flex flex-col">
|
||||
<AppHeader />
|
||||
<div className="flex-1 flex flex-col items-center justify-center p-6">
|
||||
@@ -212,7 +202,7 @@ export function ImportPage() {
|
||||
<div className="text-center mb-10">
|
||||
<div className="inline-flex items-center gap-2 text-primary text-sm font-medium mb-4 bg-primary/10 px-3 py-1 rounded-full">
|
||||
<FlaskConical className="h-3.5 w-3.5" />
|
||||
Process Cost Platform · MVP Fase 0
|
||||
InQ ROI
|
||||
</div>
|
||||
<h1 className="text-4xl font-bold text-slate-900 mb-3">
|
||||
Cuantificá el costo operativo<br />de tus procesos
|
||||
@@ -276,8 +266,8 @@ export function ImportPage() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Procesos de ejemplo — visibles directamente */}
|
||||
<div className="mt-8 w-full max-w-lg">
|
||||
{/* Procesos de ejemplo — solo para InQuality */}
|
||||
{user?.platformRole !== 'client_editor' && <div className="mt-8 w-full max-w-lg">
|
||||
<p className="text-xs text-slate-400 uppercase tracking-wide font-medium text-center mb-3">
|
||||
O cargá un proceso de ejemplo con un click
|
||||
</p>
|
||||
@@ -311,14 +301,11 @@ export function ImportPage() {
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="mt-8 text-xs text-slate-300">
|
||||
100% en tu navegador · Sin backend · Sin registro · Datos guardados localmente
|
||||
</p>
|
||||
</div>}
|
||||
|
||||
<AppFooter />
|
||||
</div>
|
||||
</div>
|
||||
</RequireRole>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useState, useMemo } from 'react'
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
import { useNavigate, useLocation } from '@tanstack/react-router'
|
||||
import {
|
||||
Upload, Search, Building2, GitBranch,
|
||||
Clock, TrendingUp, BarChart2, FileX2, X, Settings,
|
||||
@@ -383,6 +383,25 @@ function HomeView({
|
||||
// Empty state: sin grupos ni procesos (ya terminó de cargar)
|
||||
if (groups.length === 0 && processes.length === 0) {
|
||||
if (isClientRole) {
|
||||
if (user?.platformRole === 'client_editor') {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-24 gap-4">
|
||||
<Building2 className="h-16 w-16 text-muted-foreground/30" />
|
||||
<p className="text-base font-medium">Tu biblioteca está vacía</p>
|
||||
<p className="text-[13px] text-muted-foreground">
|
||||
Importá tu primer BPMN para empezar a cuantificar costos
|
||||
</p>
|
||||
<button
|
||||
onClick={() => onImport()}
|
||||
className="flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium text-white transition-opacity hover:opacity-90"
|
||||
style={{ background: '#F59845' }}
|
||||
>
|
||||
<Upload className="h-4 w-4" />
|
||||
Importar BPMN
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-24 gap-4">
|
||||
<Building2 className="h-16 w-16 text-muted-foreground/30" />
|
||||
@@ -508,7 +527,7 @@ function HomeView({
|
||||
const areaGroups = getAreaGroups(orgProcesses)
|
||||
|
||||
return (
|
||||
<div key={orgId} className="border border-border rounded-xl overflow-hidden">
|
||||
<div key={orgId} id={`org-section-${orgId}`} className="border border-border rounded-xl overflow-hidden">
|
||||
{/* Header de org — colapsable */}
|
||||
<button
|
||||
onClick={() => toggleOrg(orgId)}
|
||||
@@ -731,7 +750,7 @@ function GroupView({
|
||||
|
||||
export function LibraryPage() {
|
||||
const navigate = useNavigate()
|
||||
const { load, processes } = useLibraryStore()
|
||||
const { load, processes, isLoading } = useLibraryStore()
|
||||
const { user, isProfileLoaded } = useAuth()
|
||||
const [view, setView] = useState<'home' | 'group'>('home')
|
||||
const [activeGroupId, setActiveGroupId] = useState<string | null>(null)
|
||||
@@ -739,11 +758,26 @@ export function LibraryPage() {
|
||||
const [ownerFilter, setOwnerFilter] = useState<'all' | 'mine'>('all')
|
||||
const { message: toastMessage, show: showToast } = useToast()
|
||||
|
||||
const location = useLocation()
|
||||
const orgParam = useMemo(
|
||||
() => new URLSearchParams(location.search).get('org') ?? undefined,
|
||||
[location.search]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) return // no fetchear hasta tener usuario confirmado (evita race condition con sesión Supabase)
|
||||
load()
|
||||
}, [load, user?.id])
|
||||
|
||||
// Scroll a la sección del org si venimos del workspace
|
||||
useEffect(() => {
|
||||
if (!orgParam || isLoading || processes.length === 0) return
|
||||
requestAnimationFrame(() => {
|
||||
const el = document.getElementById(`org-section-${orgParam}`)
|
||||
el?.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||
})
|
||||
}, [orgParam, isLoading, processes.length])
|
||||
|
||||
function navigateHome() {
|
||||
setTransitioning(true)
|
||||
setTimeout(() => {
|
||||
@@ -832,7 +866,7 @@ export function LibraryPage() {
|
||||
<Settings className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
{!isClientRole && (
|
||||
{user.platformRole !== 'client_viewer' && (
|
||||
<button
|
||||
onClick={handleImport}
|
||||
className="flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium text-white transition-opacity hover:opacity-90"
|
||||
|
||||
@@ -11,6 +11,8 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@
|
||||
import { useProcessStore } from '@/store/process-store'
|
||||
import { useAuth } from '@/auth/AuthContext'
|
||||
import { formatPercent } from '@/lib/format'
|
||||
import { supabaseAreaRepo } from '@/persistence/supabase/area-repo'
|
||||
import type { Area } from '@/domain/types'
|
||||
|
||||
const CURRENCIES = [
|
||||
{ code: 'USD', label: 'USD — Dólar estadounidense' },
|
||||
@@ -28,7 +30,9 @@ export function GlobalSettingsPanel() {
|
||||
const { user } = useAuth()
|
||||
const isClientRole = user?.platformRole === 'client_editor' || user?.platformRole === 'client_viewer'
|
||||
const [localName, setLocalName] = useState('')
|
||||
const [localClient, setLocalClient] = useState('')
|
||||
const [localAreaId, setLocalAreaId] = useState<string>('__none__')
|
||||
const [areas, setAreas] = useState<Area[]>([])
|
||||
const [areasLoading, setAreasLoading] = useState(false)
|
||||
const [localCurrency, setLocalCurrency] = useState('USD')
|
||||
const [localOverhead, setLocalOverhead] = useState(20)
|
||||
const [localFrequency, setLocalFrequency] = useState(1000)
|
||||
@@ -39,7 +43,18 @@ export function GlobalSettingsPanel() {
|
||||
useEffect(() => {
|
||||
if (!currentProcess) return
|
||||
setLocalName(currentProcess.name)
|
||||
setLocalClient(currentProcess.clientName)
|
||||
setLocalAreaId(currentProcess.areaId ?? '__none__')
|
||||
|
||||
// Cargar áreas de la org del proceso
|
||||
if (currentProcess.orgId) {
|
||||
setAreasLoading(true)
|
||||
supabaseAreaRepo.getByOrg(currentProcess.orgId)
|
||||
.then(setAreas)
|
||||
.catch(() => setAreas([]))
|
||||
.finally(() => setAreasLoading(false))
|
||||
} else {
|
||||
setAreas([])
|
||||
}
|
||||
setLocalCurrency(currentProcess.currency)
|
||||
setLocalOverhead(Math.round(currentProcess.overheadPercentage * 100))
|
||||
setLocalFrequency(currentProcess.annualFrequency)
|
||||
@@ -54,7 +69,7 @@ export function GlobalSettingsPanel() {
|
||||
const clampedHorizon = Math.min(10, Math.max(1, localHorizon))
|
||||
await updateProcess({
|
||||
name: localName.trim() || 'Proceso sin nombre',
|
||||
clientName: localClient.trim(),
|
||||
areaId: localAreaId === '__none__' ? null : localAreaId,
|
||||
currency: localCurrency,
|
||||
overheadPercentage: localOverhead / 100,
|
||||
annualFrequency: Math.max(1, localFrequency),
|
||||
@@ -82,19 +97,35 @@ export function GlobalSettingsPanel() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Cliente */}
|
||||
{/* Área */}
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-medium">Cliente</Label>
|
||||
{isClientRole ? (
|
||||
<p className="text-sm text-slate-600 h-8 flex items-center px-1">{localClient || '—'}</p>
|
||||
<Label className="text-xs font-medium">Área</Label>
|
||||
{areasLoading ? (
|
||||
<div className="h-8 bg-slate-100 animate-pulse rounded-md" />
|
||||
) : !currentProcess.orgId ? (
|
||||
<p className="text-sm text-slate-400 h-8 flex items-center px-1 italic">Sin organización asignada</p>
|
||||
) : isClientRole && user?.platformRole === 'client_viewer' ? (
|
||||
<p className="text-sm text-slate-600 h-8 flex items-center px-1">{currentProcess.areaName || '—'}</p>
|
||||
) : (
|
||||
<Input
|
||||
id="client-name"
|
||||
value={localClient}
|
||||
onChange={(e) => { setLocalClient(e.target.value); markDirty() }}
|
||||
placeholder="ej. Empresa ABC"
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
<Select
|
||||
value={localAreaId}
|
||||
onValueChange={(v) => { setLocalAreaId(v); markDirty() }}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder="Sin área" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__none__" className="text-xs text-slate-400 italic">Sin área</SelectItem>
|
||||
{areas.map((a) => (
|
||||
<SelectItem key={a.id} value={a.id} className="text-xs">{a.name}</SelectItem>
|
||||
))}
|
||||
{areas.length === 0 && (
|
||||
<div className="px-2 py-1.5 text-xs text-slate-400">
|
||||
No hay áreas en esta organización
|
||||
</div>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -192,8 +192,10 @@ export function WorkspacePage() {
|
||||
: currentProcess.orgName ?? 'Biblioteca'
|
||||
|
||||
function handleBack() {
|
||||
// Navega siempre a /library (sin ?org=) hasta que exista una vista filtrada por org
|
||||
void navigate({ to: '/library' })
|
||||
void navigate({
|
||||
to: '/library',
|
||||
search: { org: currentProcess?.orgId ?? undefined },
|
||||
})
|
||||
}
|
||||
|
||||
// Label del tab "elemento" según lo seleccionado
|
||||
@@ -249,38 +251,9 @@ export function WorkspacePage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Cliente — editable inline solo para roles internos */}
|
||||
{user?.platformRole === 'client_editor' ? (
|
||||
currentProcess.clientName && (
|
||||
<p className="text-xs text-slate-400 truncate">{currentProcess.clientName}</p>
|
||||
)
|
||||
) : editingField === 'client' ? (
|
||||
<input
|
||||
autoFocus
|
||||
className="text-xs text-slate-400 bg-transparent border-b border-[#F59845] outline-none w-full"
|
||||
value={editValue}
|
||||
onChange={(e) => setEditValue(e.target.value)}
|
||||
onBlur={() => saveField('client')}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') { e.preventDefault(); saveField('client') }
|
||||
if (e.key === 'Escape') setEditingField(null)
|
||||
}}
|
||||
placeholder="Agregar cliente…"
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className="flex items-center gap-1 group cursor-text"
|
||||
onClick={() => { setEditValue(currentProcess.clientName ?? ''); setEditingField('client') }}
|
||||
>
|
||||
{currentProcess.clientName
|
||||
? <p className="text-xs text-slate-400 truncate">{currentProcess.clientName}</p>
|
||||
: <p className="text-xs text-slate-300 truncate italic">Agregar cliente…</p>
|
||||
}
|
||||
{savedField === 'client'
|
||||
? <Check className="h-3 w-3 text-[#F59845] shrink-0" />
|
||||
: <Pencil className="h-2.5 w-2.5 text-slate-300 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
}
|
||||
</div>
|
||||
{/* Área — display de solo lectura (editable desde el panel de configuración) */}
|
||||
{currentProcess.areaName && (
|
||||
<p className="text-xs text-slate-400 truncate">{currentProcess.areaName}</p>
|
||||
)}
|
||||
|
||||
{/* Tags — chips removibles + input inline */}
|
||||
|
||||
@@ -14,6 +14,7 @@ function toRow(process: Process, userId: string) {
|
||||
automation_investment: process.automationInvestment,
|
||||
group_id: process.groupId,
|
||||
area_id: process.areaId,
|
||||
org_id: process.orgId ?? null,
|
||||
tags: process.tags,
|
||||
// owner_id se preserva del valor existente en el proceso; solo se inicializa con userId en creates
|
||||
owner_id: process.ownerId || userId,
|
||||
|
||||
@@ -107,6 +107,9 @@ const libraryRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/library',
|
||||
component: LibraryPage,
|
||||
validateSearch: (search: Record<string, unknown>) => ({
|
||||
org: typeof search.org === 'string' ? search.org : undefined,
|
||||
}),
|
||||
})
|
||||
|
||||
const importRoute = createRoute({
|
||||
@@ -134,7 +137,7 @@ const settingsRoute = createRoute({
|
||||
beforeLoad: ({ context }) => {
|
||||
const role = (context as { auth?: { user?: { platformRole?: string } } }).auth?.user?.platformRole
|
||||
if (role === 'client_editor' || role === 'client_viewer') {
|
||||
throw redirect({ to: '/library' })
|
||||
throw redirect({ to: '/library', search: { org: undefined } })
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
99
supabase/functions/delete-user/index.ts
Normal file
99
supabase/functions/delete-user/index.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* Edge Function: delete-user
|
||||
* Propósito: eliminación permanente de un usuario (hard delete de auth.users + public.users).
|
||||
* Solo accesible por platform_admin. No reversible.
|
||||
* Variables de entorno: SUPABASE_URL y SUPABASE_SERVICE_ROLE_KEY (auto-disponibles en Edge Functions).
|
||||
* Deploy: supabase functions deploy delete-user
|
||||
*/
|
||||
|
||||
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'
|
||||
|
||||
interface DeleteUserRequest {
|
||||
userId: string
|
||||
}
|
||||
|
||||
Deno.serve(async (req: Request) => {
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'POST, OPTIONS',
|
||||
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
|
||||
}
|
||||
|
||||
if (req.method === 'OPTIONS') {
|
||||
return new Response('ok', { headers })
|
||||
}
|
||||
|
||||
if (req.method !== 'POST') {
|
||||
return new Response(JSON.stringify({ error: 'Método no permitido' }), { status: 405, headers })
|
||||
}
|
||||
|
||||
const adminClient = createClient(
|
||||
Deno.env.get('SUPABASE_URL')!,
|
||||
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!,
|
||||
)
|
||||
|
||||
// ── 1. Autenticar al llamador ──────────────────────────────────────────────
|
||||
|
||||
const callerToken = req.headers.get('Authorization')?.replace('Bearer ', '')
|
||||
if (!callerToken) {
|
||||
return new Response(JSON.stringify({ error: 'Authorization header requerido' }), { status: 401, headers })
|
||||
}
|
||||
|
||||
const { data: { user: caller }, error: authError } = await adminClient.auth.getUser(callerToken)
|
||||
if (authError || !caller) {
|
||||
return new Response(JSON.stringify({ error: 'Token inválido o expirado' }), { status: 401, headers })
|
||||
}
|
||||
|
||||
const { data: callerProfile, error: profileError } = await adminClient
|
||||
.from('users')
|
||||
.select('platform_role')
|
||||
.eq('id', caller.id)
|
||||
.single()
|
||||
|
||||
if (profileError || callerProfile?.platform_role !== 'platform_admin') {
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'Forbidden: solo platform_admin puede eliminar usuarios' }),
|
||||
{ status: 403, headers },
|
||||
)
|
||||
}
|
||||
|
||||
// ── 2. Parsear body ────────────────────────────────────────────────────────
|
||||
|
||||
let body: DeleteUserRequest
|
||||
try {
|
||||
body = await req.json() as DeleteUserRequest
|
||||
} catch {
|
||||
return new Response(JSON.stringify({ error: 'Body JSON inválido' }), { status: 400, headers })
|
||||
}
|
||||
|
||||
const { userId } = body
|
||||
if (!userId?.trim()) {
|
||||
return new Response(JSON.stringify({ error: 'El campo userId es requerido' }), { status: 400, headers })
|
||||
}
|
||||
|
||||
// ── 3. Prevenir auto-eliminación ──────────────────────────────────────────
|
||||
|
||||
if (userId === caller.id) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'No puedes eliminar tu propia cuenta' }),
|
||||
{ status: 400, headers },
|
||||
)
|
||||
}
|
||||
|
||||
// ── 4. Eliminar de public.users primero (CASCADE, pero por seguridad explícita) ─
|
||||
|
||||
await adminClient.from('users').delete().eq('id', userId)
|
||||
|
||||
// ── 5. Hard delete de auth.users ─────────────────────────────────────────
|
||||
|
||||
const { error: deleteError } = await adminClient.auth.admin.deleteUser(userId)
|
||||
if (deleteError) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: `Error al eliminar usuario: ${deleteError.message}` }),
|
||||
{ status: 500, headers },
|
||||
)
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify({ success: true }), { status: 200, headers })
|
||||
})
|
||||
74
supabase/functions/list-users/index.ts
Normal file
74
supabase/functions/list-users/index.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* Edge Function: list-users
|
||||
* Propósito: devuelve usuarios de public.users con email de auth.users (requiere service_role).
|
||||
* Solo accesible por platform_admin.
|
||||
* Deploy: supabase functions deploy list-users
|
||||
*/
|
||||
|
||||
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'
|
||||
|
||||
Deno.serve(async (req: Request) => {
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'POST, GET, OPTIONS',
|
||||
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
|
||||
}
|
||||
|
||||
if (req.method === 'OPTIONS') {
|
||||
return new Response('ok', { headers })
|
||||
}
|
||||
|
||||
const adminClient = createClient(
|
||||
Deno.env.get('SUPABASE_URL')!,
|
||||
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!,
|
||||
)
|
||||
|
||||
// ── 1. Autenticar al llamador ──────────────────────────────────────────────
|
||||
|
||||
const callerToken = req.headers.get('Authorization')?.replace('Bearer ', '')
|
||||
if (!callerToken) {
|
||||
return new Response(JSON.stringify({ error: 'Authorization header requerido' }), { status: 401, headers })
|
||||
}
|
||||
|
||||
const { data: { user: caller }, error: authError } = await adminClient.auth.getUser(callerToken)
|
||||
if (authError || !caller) {
|
||||
return new Response(JSON.stringify({ error: 'Token inválido o expirado' }), { status: 401, headers })
|
||||
}
|
||||
|
||||
const { data: callerProfile, error: profileError } = await adminClient
|
||||
.from('users')
|
||||
.select('platform_role')
|
||||
.eq('id', caller.id)
|
||||
.single()
|
||||
|
||||
if (profileError || callerProfile?.platform_role !== 'platform_admin') {
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'Forbidden: solo platform_admin puede listar usuarios con email' }),
|
||||
{ status: 403, headers },
|
||||
)
|
||||
}
|
||||
|
||||
// ── 2. Obtener emails de auth.users ───────────────────────────────────────
|
||||
|
||||
const { data: authData, error: listError } = await adminClient.auth.admin.listUsers({
|
||||
page: 1,
|
||||
perPage: 1000,
|
||||
})
|
||||
|
||||
if (listError) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: `Error al listar usuarios: ${listError.message}` }),
|
||||
{ status: 500, headers },
|
||||
)
|
||||
}
|
||||
|
||||
// ── 3. Devolver mapa id → email ───────────────────────────────────────────
|
||||
|
||||
const result = (authData?.users ?? []).map((u) => ({
|
||||
id: u.id,
|
||||
email: u.email ?? null,
|
||||
}))
|
||||
|
||||
return new Response(JSON.stringify(result), { status: 200, headers })
|
||||
})
|
||||
15
supabase/migrations/018_client_editor_insert.sql
Normal file
15
supabase/migrations/018_client_editor_insert.sql
Normal file
@@ -0,0 +1,15 @@
|
||||
-- Permite que client_editor inserte procesos en su propia org
|
||||
-- Los demás roles mantienen sus permisos actuales
|
||||
|
||||
DROP POLICY IF EXISTS "insert_processes" ON public.processes;
|
||||
|
||||
CREATE POLICY "insert_processes" ON public.processes
|
||||
FOR INSERT WITH CHECK (
|
||||
public.is_platform_admin()
|
||||
OR public.current_platform_role() = 'member'
|
||||
OR (
|
||||
public.current_platform_role() = 'client_editor'
|
||||
AND org_id = public.current_org()
|
||||
)
|
||||
-- client_viewer y guest siguen sin poder crear procesos
|
||||
);
|
||||
143
supabase/seed_client_processes.sql
Normal file
143
supabase/seed_client_processes.sql
Normal file
@@ -0,0 +1,143 @@
|
||||
-- seed_client_processes.sql
|
||||
-- Distribuye procesos de InQuality a los demás clientes para testing.
|
||||
-- Duplica entre 5 y 10 procesos aleatorios por org (deep copy: proceso + actividades + gateways + resource assignments).
|
||||
-- Los procesos de InQuality NO se modifican ni eliminan.
|
||||
-- Ejecutar desde el SQL Editor de Supabase (o psql con service_role).
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
inquality_org_id uuid;
|
||||
client_org record;
|
||||
source_proc record;
|
||||
new_proc_id uuid;
|
||||
source_act record;
|
||||
new_act_id uuid;
|
||||
proc_count int := 0;
|
||||
BEGIN
|
||||
-- Obtener InQuality (is_provider = true)
|
||||
SELECT id INTO inquality_org_id
|
||||
FROM organizations
|
||||
WHERE is_provider = true
|
||||
LIMIT 1;
|
||||
|
||||
IF inquality_org_id IS NULL THEN
|
||||
RAISE EXCEPTION 'No se encontró una organización con is_provider = true';
|
||||
END IF;
|
||||
|
||||
RAISE NOTICE 'InQuality org_id: %', inquality_org_id;
|
||||
|
||||
-- Para cada org cliente
|
||||
FOR client_org IN
|
||||
SELECT id, name
|
||||
FROM organizations
|
||||
WHERE is_provider = false
|
||||
ORDER BY name
|
||||
LOOP
|
||||
proc_count := 0;
|
||||
RAISE NOTICE '→ Distribuyendo a: %', client_org.name;
|
||||
|
||||
-- Seleccionar entre 5 y 10 procesos aleatorios de InQuality
|
||||
FOR source_proc IN
|
||||
SELECT *
|
||||
FROM processes
|
||||
WHERE org_id = inquality_org_id
|
||||
ORDER BY RANDOM()
|
||||
LIMIT 5 + FLOOR(RANDOM() * 6)::int -- 5..10 inclusive
|
||||
LOOP
|
||||
new_proc_id := gen_random_uuid();
|
||||
|
||||
-- 1. Duplicar proceso
|
||||
INSERT INTO processes (
|
||||
id, name, client, bpmn_xml, currency,
|
||||
overhead_percentage, annual_frequency, analysis_horizon_years,
|
||||
automation_investment, group_id, area_id, org_id, tags,
|
||||
owner_id, created_by, updated_by, updated_at
|
||||
) VALUES (
|
||||
new_proc_id,
|
||||
source_proc.name,
|
||||
source_proc.client,
|
||||
source_proc.bpmn_xml,
|
||||
source_proc.currency,
|
||||
source_proc.overhead_percentage,
|
||||
source_proc.annual_frequency,
|
||||
source_proc.analysis_horizon_years,
|
||||
source_proc.automation_investment,
|
||||
NULL, -- group_id: columna legacy, no asignar
|
||||
NULL, -- area_id: sin área; asignar manualmente si aplica
|
||||
client_org.id, -- org destino
|
||||
source_proc.tags,
|
||||
source_proc.owner_id,
|
||||
source_proc.created_by,
|
||||
source_proc.updated_by,
|
||||
NOW()
|
||||
);
|
||||
|
||||
-- 2. Duplicar actividades + sus resource assignments
|
||||
FOR source_act IN
|
||||
SELECT * FROM activities WHERE process_id = source_proc.id
|
||||
LOOP
|
||||
new_act_id := gen_random_uuid();
|
||||
|
||||
INSERT INTO activities (
|
||||
id, process_id, bpmn_element_id, name, type,
|
||||
direct_cost_fixed, execution_time_minutes,
|
||||
automatable, automated_cost_fixed, automated_time_minutes,
|
||||
created_by, updated_by, created_at, updated_at
|
||||
) VALUES (
|
||||
new_act_id,
|
||||
new_proc_id,
|
||||
source_act.bpmn_element_id,
|
||||
source_act.name,
|
||||
source_act.type,
|
||||
source_act.direct_cost_fixed,
|
||||
source_act.execution_time_minutes,
|
||||
source_act.automatable,
|
||||
source_act.automated_cost_fixed,
|
||||
source_act.automated_time_minutes,
|
||||
source_act.created_by,
|
||||
source_act.updated_by,
|
||||
NOW(),
|
||||
NOW()
|
||||
);
|
||||
|
||||
-- Copiar resource assignments para esta actividad (si los hay)
|
||||
INSERT INTO activity_resource_assignments (
|
||||
id, activity_id, resource_id, utilization_minutes, units, scenario
|
||||
)
|
||||
SELECT
|
||||
gen_random_uuid(),
|
||||
new_act_id,
|
||||
resource_id,
|
||||
utilization_minutes,
|
||||
units,
|
||||
scenario
|
||||
FROM activity_resource_assignments
|
||||
WHERE activity_id = source_act.id;
|
||||
|
||||
END LOOP;
|
||||
|
||||
-- 3. Duplicar gateways
|
||||
INSERT INTO gateways (
|
||||
id, process_id, bpmn_element_id, gateway_type, branches,
|
||||
created_at, updated_at
|
||||
)
|
||||
SELECT
|
||||
gen_random_uuid(),
|
||||
new_proc_id,
|
||||
bpmn_element_id,
|
||||
gateway_type,
|
||||
branches,
|
||||
NOW(),
|
||||
NOW()
|
||||
FROM gateways
|
||||
WHERE process_id = source_proc.id;
|
||||
|
||||
proc_count := proc_count + 1;
|
||||
RAISE NOTICE ' ✓ %', source_proc.name;
|
||||
END LOOP;
|
||||
|
||||
RAISE NOTICE ' → % procesos duplicados para %', proc_count, client_org.name;
|
||||
END LOOP;
|
||||
|
||||
RAISE NOTICE 'Script completado.';
|
||||
END $$;
|
||||
@@ -74,6 +74,7 @@ vi.mock('@/components/AppHeader', () => ({ AppHeader: () => null }))
|
||||
vi.mock('@tanstack/react-router', () => ({
|
||||
useNavigate: () => vi.fn(),
|
||||
useRouterState: () => ({ location: { search: '' } }),
|
||||
useLocation: () => ({ search: '' }),
|
||||
Link: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
}))
|
||||
|
||||
@@ -133,9 +134,9 @@ describe('LibraryPage — visibilidad de controles según rol', () => {
|
||||
authUserRef.current = { ...authUserRef.current, platformRole: 'client_editor' }
|
||||
})
|
||||
|
||||
it('NO muestra el botón "Importar BPMN"', () => {
|
||||
it('muestra el botón "Importar BPMN"', () => {
|
||||
render(<LibraryPage />)
|
||||
expect(screen.queryByText('Importar BPMN')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('Importar BPMN')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('NO muestra el panel de grupos', () => {
|
||||
|
||||
Reference in New Issue
Block a user