23 KiB
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
areasen DB,area-repo.tscon CRUD,areaId/areaNameen tipos y repos)
Objetivo
Conectar el campo area_id al flujo de trabajo real del usuario en dos lugares:
- 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í. - WorkspacePage.tsx: reemplazar la edición inline de
clientNameen el topbar por un display de solo lectura delareaName. - 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" conlocalClientstate,Inputeditable para admin,<p>read-only para cliente.src/features/workspace/WorkspacePage.tsx— bloque "Cliente — editable inline" en el topbar (líneas ~254-276), funciónsaveField('client')(línea ~125).src/features/admin/OrganizationDetailPage.tsx— dos tabs existentes: Procesos y Usuarios. UsaSheetySelectde 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—Processya tieneareaId: string | nullyareaName?: string | null.src/persistence/supabase/process-repo.ts—updateEditable()ya incluyearea_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:
const [localClient, setLocalClient] = useState('')
Agregar:
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
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:
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:
clientName: localClient.trim(),
Agregar:
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"):
<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):
<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_viewernunca llega al workspace (redirigido en WorkspacePage). La condiciónisClientRole && user?.platformRole === 'client_viewer'es por seguridad.client_editorsí puede seleccionar área — la RLS deareas_selectle retorna las áreas de su org.- Si
currentProcess.orgIdes null (edge case post-migración), mostrar texto descriptivo en lugar del dropdown. Selectya 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):
{/* 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):
{/* Á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
// Á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)
interface AreaWithCount {
id: string
name: string
description: string | null
processCount: number
}
Y extender OrgProcess con area_id:
interface OrgProcess {
id: string
name: string
client: string | null
area_id: string | null // 🆕 para calcular processCount por área
updated_at: string
}
Import a agregar
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:
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
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:
<TabsTrigger value="areas">
<FolderOpen className="h-4 w-4 mr-1.5" />
Áreas ({areas.length})
</TabsTrigger>
Y el contenido del tab:
{/* ─── 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:
{/* ─── 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 tieneareaId,areaName)src/persistence/supabase/area-repo.ts— no tocar (CRUD ya implementado)src/persistence/supabase/process-repo.ts— no tocarsrc/store/process-store.ts— no tocarsrc/features/library/LibraryPage.tsx— no tocarsrc/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_iden 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 buildsin errores TypeScriptnpm run testsin regresiones (596 tests — esta etapa no agrega tests, pero no puede romper existentes)- No hay
anyintroducido sin justificación
Restricciones explícitas
- NO eliminar
clientNamenigroupIddel dominio ni de los repositorios — sigue coexistiendo - NO modificar
process-store.ts— el store ya manejaareaIdví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
- Confirmar que el campo "Cliente" en
GlobalSettingsPanelya no existe y fue reemplazado por el selector de área - Confirmar que el topbar de WorkspacePage ya no tiene edición inline de clientName
- Confirmar que
OrganizationDetailPagetiene un tercer tab "Áreas" funcional - Lista compacta de archivos modificados con resumen de cambios
- Resultado de
npm run buildynpm run test(con conteo de tests) - 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