Compare commits
5 Commits
de00e3a902
...
53f8a8d8cd
| Author | SHA1 | Date | |
|---|---|---|---|
| 53f8a8d8cd | |||
| 6e60bf4def | |||
| fe33c910ac | |||
| b634d061b7 | |||
| e0b4607603 |
@@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
### Added
|
### 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)
|
- LibraryPage: agrupación org → área con headers colapsables para admin (Sprint 8)
|
||||||
- Settings ⚙ oculto para roles cliente + guard beforeLoad en /settings (Sprint 8)
|
- Settings ⚙ oculto para roles cliente + guard beforeLoad en /settings (Sprint 8)
|
||||||
- Interface `Area` en domain/types.ts (Sprint 8)
|
- Interface `Area` en domain/types.ts (Sprint 8)
|
||||||
|
|||||||
622
sprints/sprint-8/ETAPA_4_PROMPT.md
Normal file
622
sprints/sprint-8/ETAPA_4_PROMPT.md
Normal 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
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { useParams, useNavigate, Link } from '@tanstack/react-router'
|
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 { supabase } from '@/lib/supabase'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Skeleton } from '@/components/ui/skeleton'
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
@@ -20,14 +20,25 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select'
|
} from '@/components/ui/select'
|
||||||
import { RoleBadge } from './RoleBadge'
|
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 {
|
interface OrgProcess {
|
||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
client: string | null
|
client: string | null
|
||||||
|
area_id: string | null
|
||||||
updated_at: string
|
updated_at: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface AreaWithCount {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
description: string | null
|
||||||
|
processCount: number
|
||||||
|
}
|
||||||
|
|
||||||
interface OrgUser {
|
interface OrgUser {
|
||||||
id: string
|
id: string
|
||||||
name: string | null
|
name: string | null
|
||||||
@@ -66,16 +77,53 @@ export function OrganizationDetailPage() {
|
|||||||
const [assigning, setAssigning] = useState(false)
|
const [assigning, setAssigning] = useState(false)
|
||||||
const [assignError, setAssignError] = useState<string | null>(null)
|
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 fetchDetail = async () => {
|
||||||
const [orgRes, procRes, userRes] = await Promise.all([
|
const [orgRes, procRes, userRes] = await Promise.all([
|
||||||
supabase.from('organizations').select('name').eq('id', orgId).single(),
|
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'),
|
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)
|
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) ?? [])
|
setUsers((userRes.data as OrgUser[] | null) ?? [])
|
||||||
setLoading(false)
|
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])
|
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) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
@@ -148,6 +243,10 @@ export function OrganizationDetailPage() {
|
|||||||
<Users className="h-4 w-4 mr-1.5" />
|
<Users className="h-4 w-4 mr-1.5" />
|
||||||
Usuarios ({users.length})
|
Usuarios ({users.length})
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="areas">
|
||||||
|
<FolderOpen className="h-4 w-4 mr-1.5" />
|
||||||
|
Áreas ({areas.length})
|
||||||
|
</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
{/* ─── Tab: Procesos ─────────────────────────────────────────────── */}
|
{/* ─── Tab: Procesos ─────────────────────────────────────────────── */}
|
||||||
@@ -240,6 +339,105 @@ export function OrganizationDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</TabsContent>
|
</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>
|
</Tabs>
|
||||||
|
|
||||||
{/* ─── Sheet: Asignar proceso ─────────────────────────────────────── */}
|
{/* ─── Sheet: Asignar proceso ─────────────────────────────────────── */}
|
||||||
@@ -277,6 +475,40 @@ export function OrganizationDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
</SheetContent>
|
</SheetContent>
|
||||||
</Sheet>
|
</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>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@
|
|||||||
import { useProcessStore } from '@/store/process-store'
|
import { useProcessStore } from '@/store/process-store'
|
||||||
import { useAuth } from '@/auth/AuthContext'
|
import { useAuth } from '@/auth/AuthContext'
|
||||||
import { formatPercent } from '@/lib/format'
|
import { formatPercent } from '@/lib/format'
|
||||||
|
import { supabaseAreaRepo } from '@/persistence/supabase/area-repo'
|
||||||
|
import type { Area } from '@/domain/types'
|
||||||
|
|
||||||
const CURRENCIES = [
|
const CURRENCIES = [
|
||||||
{ code: 'USD', label: 'USD — Dólar estadounidense' },
|
{ code: 'USD', label: 'USD — Dólar estadounidense' },
|
||||||
@@ -28,7 +30,9 @@ export function GlobalSettingsPanel() {
|
|||||||
const { user } = useAuth()
|
const { user } = useAuth()
|
||||||
const isClientRole = user?.platformRole === 'client_editor' || user?.platformRole === 'client_viewer'
|
const isClientRole = user?.platformRole === 'client_editor' || user?.platformRole === 'client_viewer'
|
||||||
const [localName, setLocalName] = useState('')
|
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 [localCurrency, setLocalCurrency] = useState('USD')
|
||||||
const [localOverhead, setLocalOverhead] = useState(20)
|
const [localOverhead, setLocalOverhead] = useState(20)
|
||||||
const [localFrequency, setLocalFrequency] = useState(1000)
|
const [localFrequency, setLocalFrequency] = useState(1000)
|
||||||
@@ -39,7 +43,18 @@ export function GlobalSettingsPanel() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!currentProcess) return
|
if (!currentProcess) return
|
||||||
setLocalName(currentProcess.name)
|
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)
|
setLocalCurrency(currentProcess.currency)
|
||||||
setLocalOverhead(Math.round(currentProcess.overheadPercentage * 100))
|
setLocalOverhead(Math.round(currentProcess.overheadPercentage * 100))
|
||||||
setLocalFrequency(currentProcess.annualFrequency)
|
setLocalFrequency(currentProcess.annualFrequency)
|
||||||
@@ -54,7 +69,7 @@ export function GlobalSettingsPanel() {
|
|||||||
const clampedHorizon = Math.min(10, Math.max(1, localHorizon))
|
const clampedHorizon = Math.min(10, Math.max(1, localHorizon))
|
||||||
await updateProcess({
|
await updateProcess({
|
||||||
name: localName.trim() || 'Proceso sin nombre',
|
name: localName.trim() || 'Proceso sin nombre',
|
||||||
clientName: localClient.trim(),
|
areaId: localAreaId === '' ? null : localAreaId,
|
||||||
currency: localCurrency,
|
currency: localCurrency,
|
||||||
overheadPercentage: localOverhead / 100,
|
overheadPercentage: localOverhead / 100,
|
||||||
annualFrequency: Math.max(1, localFrequency),
|
annualFrequency: Math.max(1, localFrequency),
|
||||||
@@ -82,19 +97,35 @@ export function GlobalSettingsPanel() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Cliente */}
|
{/* Área */}
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label className="text-xs font-medium">Cliente</Label>
|
<Label className="text-xs font-medium">Área</Label>
|
||||||
{isClientRole ? (
|
{areasLoading ? (
|
||||||
<p className="text-sm text-slate-600 h-8 flex items-center px-1">{localClient || '—'}</p>
|
<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
|
<Select
|
||||||
id="client-name"
|
value={localAreaId}
|
||||||
value={localClient}
|
onValueChange={(v) => { setLocalAreaId(v); markDirty() }}
|
||||||
onChange={(e) => { setLocalClient(e.target.value); markDirty() }}
|
>
|
||||||
placeholder="ej. Empresa ABC"
|
<SelectTrigger className="h-8 text-xs">
|
||||||
className="h-8 text-sm"
|
<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>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -249,38 +249,9 @@ export function WorkspacePage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Cliente — editable inline solo para roles internos */}
|
{/* Área — display de solo lectura (editable desde el panel de configuración) */}
|
||||||
{user?.platformRole === 'client_editor' ? (
|
{currentProcess.areaName && (
|
||||||
currentProcess.clientName && (
|
<p className="text-xs text-slate-400 truncate">{currentProcess.areaName}</p>
|
||||||
<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>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Tags — chips removibles + input inline */}
|
{/* Tags — chips removibles + input inline */}
|
||||||
|
|||||||
Reference in New Issue
Block a user