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 { 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>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user