Compare commits

...

5 Commits

Author SHA1 Message Date
53f8a8d8cd etapa 4 terminada
Some checks failed
/ Deploy to Cloudflare Pages (push) Has been cancelled
2026-07-07 21:12:00 -03:00
6e60bf4def docs(changelog): etapa 4 sprint 8
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-07 21:06:59 -03:00
fe33c910ac feat(admin): CRUD de áreas en OrganizationDetailPage — tab Áreas con crear/renombrar/eliminar
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-07 21:06:46 -03:00
b634d061b7 feat(workspace): topbar muestra areaName en lugar de clientName inline-editable
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-07 21:06:42 -03:00
e0b4607603 feat(workspace): selector de área en GlobalSettingsPanel reemplaza campo libre "Cliente"
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-07 21:06:39 -03:00
5 changed files with 908 additions and 49 deletions

View File

@@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Added
- 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)

View 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

View File

@@ -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>
</>
)
}

View File

@@ -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>('')
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 ?? '')
// 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 === '' ? 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="" 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>

View File

@@ -249,38 +249,9 @@ export function WorkspacePage() {
</div>
)}
{/* Clienteeditable 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 */}