# 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
