feat(admin): CRUD de áreas en OrganizationDetailPage — tab Áreas con crear/renombrar/eliminar

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-07-07 21:06:46 -03:00
parent b634d061b7
commit fe33c910ac

View File

@@ -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>
</> </>
) )
} }