feat(sprint-5/etapa-4): procesos por recurso + campo empresa en perfil

Feature A — Ver procesos asociados a un recurso:
- resource-repo.ts: getProcessCountsPerResource() (una query, N+1 evitado)
- resource-repo.ts: getProcessesForResource() (on-demand al expandir)
- ResourceCatalogPage: carga conteos en paralelo, columna "Procesos" con
  badge naranja clicable, fila expandida con links a /workspace/:id

Feature B — Campo empresa en perfil:
- 013_add_company_to_users.sql: ALTER TABLE users ADD COLUMN IF NOT EXISTS company text
- AuthUser: agrega company? opcional
- SupabaseAuthService: lee company y name de tabla users en onAuthStateChange
- AuthContext: agrega updateUser() para reflejar cambios sin recargar
- ProfileSheet.tsx: Sheet con nombre/empresa editables, email read-only
- AppHeader: muestra empresa en dropdown + item "Editar perfil"

528 tests Vitest verdes. Build limpio.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-19 19:56:13 -03:00
parent e8a4a1b3e0
commit 6d14e64f33
8 changed files with 354 additions and 117 deletions

View File

@@ -1,6 +1,6 @@
import { useEffect, useState } from 'react'
import { useNavigate, useSearch } from '@tanstack/react-router'
import { Users2, Search, Plus, Pencil, Trash2, ArrowLeft, CornerUpLeft } from 'lucide-react'
import { useNavigate, useSearch, Link } from '@tanstack/react-router'
import { Users2, Search, Plus, Pencil, Trash2, ArrowLeft, CornerUpLeft, ChevronDown, ChevronRight } from 'lucide-react'
import { AppHeader } from '@/components/AppHeader'
import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button'
@@ -24,6 +24,9 @@ export function ResourceCatalogPage() {
const fromProcess = (search_ as { fromProcess?: string }).fromProcess
const [fromProcessName, setFromProcessName] = useState<string | null>(null)
const [resources, setResources] = useState<ResourceWithCreator[]>([])
const [processCounts, setProcessCounts] = useState<Record<string, number>>({})
const [expandedResourceId, setExpandedResourceId] = useState<string | null>(null)
const [expandedProcesses, setExpandedProcesses] = useState<Record<string, { id: string; name: string }[]>>({})
const [isLoading, setIsLoading] = useState(true)
const [search, setSearch] = useState('')
const [typeFilter, setTypeFilter] = useState<ResourceType | 'all'>('all')
@@ -44,8 +47,12 @@ export function ResourceCatalogPage() {
async function load() {
setIsLoading(true)
try {
const data = await supabaseResourceRepo.getAllWithCreator()
const [data, counts] = await Promise.all([
supabaseResourceRepo.getAllWithCreator(),
supabaseResourceRepo.getProcessCountsPerResource(),
])
setResources(data)
setProcessCounts(counts)
} finally {
setIsLoading(false)
}
@@ -55,6 +62,18 @@ export function ResourceCatalogPage() {
load()
}, [])
async function toggleExpanded(resourceId: string) {
if (expandedResourceId === resourceId) {
setExpandedResourceId(null)
return
}
setExpandedResourceId(resourceId)
if (!expandedProcesses[resourceId]) {
const processes = await supabaseResourceRepo.getProcessesForResource(resourceId)
setExpandedProcesses((prev) => ({ ...prev, [resourceId]: processes }))
}
}
const filtered = resources.filter((r) => {
const matchesName = r.name.toLowerCase().includes(search.toLowerCase())
const matchesType = typeFilter === 'all' || r.type === typeFilter
@@ -182,68 +201,118 @@ export function ResourceCatalogPage() {
<TableHead>Tipo</TableHead>
<TableHead>Costo/h</TableHead>
<TableHead>Creado por</TableHead>
<TableHead>Procesos</TableHead>
<TableHead className="text-right">Acciones</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filtered.map((resource) => (
<TableRow key={resource.id}>
<TableCell>
<div className="flex items-center gap-2.5">
<ResourceTypeAvatar type={resource.type} size={28} />
<span className="text-[13px] font-medium">{resource.name}</span>
</div>
</TableCell>
<TableCell>
<Badge
variant="outline"
style={{
background: RESOURCE_TYPE_STYLES[resource.type].bg,
color: RESOURCE_TYPE_STYLES[resource.type].fg,
borderColor: 'transparent',
}}
>
{RESOURCE_TYPE_LABELS[resource.type]}
</Badge>
</TableCell>
<TableCell className="font-mono text-[13px]">{formatCostPerHour(resource.costPerHour)}</TableCell>
<TableCell className="text-[13px] text-muted-foreground">{resource.creatorName}</TableCell>
<TableCell className="text-right">
{confirmingDeleteId === resource.id ? (
<div className="flex items-center justify-end gap-1.5">
<span className="text-[11px] text-muted-foreground">¿Eliminar?</span>
<Button size="sm" variant="destructive" className="h-7 text-[11px]" onClick={() => handleDelete(resource.id)}>
</Button>
<Button size="sm" variant="outline" className="h-7 text-[11px]" onClick={() => setConfirmingDeleteId(null)}>
No
</Button>
</div>
) : (
<div className="flex items-center justify-end gap-1">
{fromProcess && (
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon" className="h-7 w-7 text-muted-foreground hover:text-foreground" onClick={backToProcess}>
<CornerUpLeft className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent side="top" className="max-w-48 text-xs">
Volver al workspace para asignar este recurso a una actividad
</TooltipContent>
</Tooltip>
{filtered.map((resource) => {
const count = processCounts[resource.id] ?? 0
const isExpanded = expandedResourceId === resource.id
return (
<>
<TableRow key={resource.id}>
<TableCell>
<div className="flex items-center gap-2.5">
<ResourceTypeAvatar type={resource.type} size={28} />
<span className="text-[13px] font-medium">{resource.name}</span>
</div>
</TableCell>
<TableCell>
<Badge
variant="outline"
style={{
background: RESOURCE_TYPE_STYLES[resource.type].bg,
color: RESOURCE_TYPE_STYLES[resource.type].fg,
borderColor: 'transparent',
}}
>
{RESOURCE_TYPE_LABELS[resource.type]}
</Badge>
</TableCell>
<TableCell className="font-mono text-[13px]">{formatCostPerHour(resource.costPerHour)}</TableCell>
<TableCell className="text-[13px] text-muted-foreground">{resource.creatorName}</TableCell>
<TableCell>
{count > 0 ? (
<button
type="button"
onClick={() => toggleExpanded(resource.id)}
className="inline-flex items-center gap-1 text-xs font-medium text-orange-600 bg-orange-50 hover:bg-orange-100 rounded-md px-2 py-0.5 transition-colors"
>
{isExpanded ? <ChevronDown className="h-3 w-3" /> : <ChevronRight className="h-3 w-3" />}
{count} {count === 1 ? 'proceso' : 'procesos'}
</button>
) : (
<span className="text-muted-foreground text-[13px]"></span>
)}
<Button variant="ghost" size="icon" className="h-7 w-7 text-muted-foreground hover:text-foreground" onClick={() => startEdit(resource)}>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button variant="ghost" size="icon" className="h-7 w-7 text-muted-foreground hover:text-destructive" onClick={() => setConfirmingDeleteId(resource.id)}>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</TableCell>
<TableCell className="text-right">
{confirmingDeleteId === resource.id ? (
<div className="flex items-center justify-end gap-1.5">
<span className="text-[11px] text-muted-foreground">¿Eliminar?</span>
<Button size="sm" variant="destructive" className="h-7 text-[11px]" onClick={() => handleDelete(resource.id)}>
</Button>
<Button size="sm" variant="outline" className="h-7 text-[11px]" onClick={() => setConfirmingDeleteId(null)}>
No
</Button>
</div>
) : (
<div className="flex items-center justify-end gap-1">
{fromProcess && (
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon" className="h-7 w-7 text-muted-foreground hover:text-foreground" onClick={backToProcess}>
<CornerUpLeft className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent side="top" className="max-w-48 text-xs">
Volver al workspace para asignar este recurso a una actividad
</TooltipContent>
</Tooltip>
)}
<Button variant="ghost" size="icon" className="h-7 w-7 text-muted-foreground hover:text-foreground" onClick={() => startEdit(resource)}>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button variant="ghost" size="icon" className="h-7 w-7 text-muted-foreground hover:text-destructive" onClick={() => setConfirmingDeleteId(resource.id)}>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
)}
</TableCell>
</TableRow>
{isExpanded && (
<TableRow key={`${resource.id}-processes`} className="bg-orange-50/40 hover:bg-orange-50/40">
<TableCell colSpan={6} className="px-4 pb-3 pt-1">
<p className="text-[10px] font-semibold text-slate-400 uppercase tracking-wide mb-1.5">
Procesos que usan este recurso
</p>
{expandedProcesses[resource.id] ? (
<div className="flex flex-wrap gap-2">
{expandedProcesses[resource.id].map((p) => (
<Link
key={p.id}
to="/workspace/$processId"
params={{ processId: p.id }}
className="text-xs text-blue-600 hover:underline"
>
{p.name}
</Link>
))}
{expandedProcesses[resource.id].length === 0 && (
<span className="text-xs text-muted-foreground">Sin procesos encontrados</span>
)}
</div>
) : (
<span className="text-xs text-muted-foreground">Cargando...</span>
)}
</TableCell>
</TableRow>
)}
</TableCell>
</TableRow>
))}
</>
)
})}
</TableBody>
</Table>
)}