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:
@@ -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)}>
|
||||
Sí
|
||||
</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)}>
|
||||
Sí
|
||||
</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>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user