# 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 `` 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
{isClientRole ? (

{localClient || '—'}

) : ( { setLocalClient(e.target.value); markDirty() }} placeholder="ej. Empresa ABC" className="h-8 text-sm" /> )}
``` **Después (selector de área):** ```tsx
{areasLoading ? (
) : !currentProcess.orgId ? (

Sin organización asignada

) : isClientRole && user?.platformRole === 'client_viewer' ? (

{currentProcess.areaName || '—'}

) : ( )}
``` **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 && (

{currentProcess.clientName}

) ) : editingField === 'client' ? ( 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…" /> ) : (
{ setEditValue(currentProcess.clientName ?? ''); setEditingField('client') }} > {currentProcess.clientName ?

{currentProcess.clientName}

:

Agregar cliente…

} {savedField === 'client' ? : }
)} ``` **Después (display de areaName, solo lectura):** ```tsx {/* Área — display de solo lectura (editable desde el panel de configuración) */} {currentProcess.areaName && (

{currentProcess.areaName}

)} ``` ### 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([]) const [areasLoading, setAreasLoading] = useState(false) // Sheet crear/editar área const [areaSheetOpen, setAreaSheetOpen] = useState(false) const [editingAreaId, setEditingAreaId] = useState(null) // null = crear nuevo const [areaNameInput, setAreaNameInput] = useState('') const [savingArea, setSavingArea] = useState(false) const [areaFormError, setAreaFormError] = useState(null) // Confirmación de delete const [deleteConfirmId, setDeleteConfirmId] = useState(null) const [deletingAreaId, setDeletingAreaId] = useState(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 ``, agregar después del tab de Usuarios: ```tsx Áreas ({areas.length}) ``` Y el contenido del tab: ```tsx {/* ─── Tab: Áreas ──────────────────────────────────────────────── */}

{areasLoading ? 'Cargando áreas…' : areas.length === 0 ? 'No hay áreas en esta organización.' : `${areas.length} área${areas.length !== 1 ? 's' : ''}.`}

{areasLoading ? (
) : areas.length === 0 ? ( } message="Las áreas agrupan procesos dentro de una organización (ej: Operaciones, RR.HH., Finanzas)." /> ) : (
{areas.map((area) => ( ))}
Área Procesos
{area.name} {area.processCount} {area.processCount === 1 ? 'proceso' : 'procesos'} {deleteConfirmId === area.id ? ( /* Confirmación inline */
{area.processCount > 0 ? `${area.processCount} proceso${area.processCount !== 1 ? 's' : ''} quedarán sin área.` : '¿Eliminar esta área?'}
) : (
)}
)}
``` ### Agregar Sheet para crear/editar área Después del Sheet existente de "Asignar proceso", agregar: ```tsx {/* ─── Sheet: Crear/editar área ────────────────────────────────── */} {editingAreaId ? 'Renombrar área' : `Nueva área en ${orgName}`}
{ 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 && (

{areaFormError}

)}
``` --- ## 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