Files
inq-roi-simulador-web/sprints/sprint-8/ETAPA_4_PROMPT.md
Marcos Benítez 53f8a8d8cd
Some checks failed
/ Deploy to Cloudflare Pages (push) Has been cancelled
etapa 4 terminada
2026-07-07 21:12:00 -03:00

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 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.tsProcess ya tiene areaId: string | null y areaName?: string | null.
  • src/persistence/supabase/process-repo.tsupdateEditable() 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:

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_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):

{/* 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 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