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:
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
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 { Button } from '@/components/ui/button'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
@@ -20,14 +20,25 @@ import {
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
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 {
|
||||
id: string
|
||||
name: string
|
||||
client: string | null
|
||||
area_id: string | null
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
interface AreaWithCount {
|
||||
id: string
|
||||
name: string
|
||||
description: string | null
|
||||
processCount: number
|
||||
}
|
||||
|
||||
interface OrgUser {
|
||||
id: string
|
||||
name: string | null
|
||||
@@ -66,16 +77,53 @@ export function OrganizationDetailPage() {
|
||||
const [assigning, setAssigning] = useState(false)
|
||||
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 [orgRes, procRes, userRes] = await Promise.all([
|
||||
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'),
|
||||
])
|
||||
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) ?? [])
|
||||
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])
|
||||
@@ -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) {
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
@@ -148,6 +243,10 @@ export function OrganizationDetailPage() {
|
||||
<Users className="h-4 w-4 mr-1.5" />
|
||||
Usuarios ({users.length})
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="areas">
|
||||
<FolderOpen className="h-4 w-4 mr-1.5" />
|
||||
Áreas ({areas.length})
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* ─── Tab: Procesos ─────────────────────────────────────────────── */}
|
||||
@@ -240,6 +339,105 @@ export function OrganizationDetailPage() {
|
||||
</div>
|
||||
)}
|
||||
</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>
|
||||
|
||||
{/* ─── Sheet: Asignar proceso ─────────────────────────────────────── */}
|
||||
@@ -277,6 +475,40 @@ export function OrganizationDetailPage() {
|
||||
</div>
|
||||
</SheetContent>
|
||||
</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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user