Sprint 4 — Etapa 7 completada. Build limpio, 521/521 tests verdes. Resumen:
Some checks failed
/ Deploy to Cloudflare Pages (push) Has been cancelled
Some checks failed
/ Deploy to Cloudflare Pages (push) Has been cancelled
Tipos: Resource.processId? eliminado (vestigial); ResourceType ya tenía los 5 valores correctos, sin cambios. resource-repo.ts: agregado getAllWithCreator() con JOIN a users!created_by (mismo patrón que Etapa 6) y tipo ResourceWithCreator. delete() ahora valida que no haya asignaciones activas en activity_resource_assignments antes de borrar — lanza error explicativo si las hay. Verifiqué ambos contra Supabase real: el JOIN funciona (FK reconocida por PostgREST) y el conteo de guarda también. Nuevo — src/features/resources/: resource-display.tsx — labels, colores/íconos por tipo (Lucide: User/Briefcase/Server/Wrench/Package) y ResourceTypeAvatar compartido ResourceFormSheet.tsx — Sheet reutilizable para crear/editar, usado tanto en el catálogo como en el panel del workspace ResourceCatalogPage.tsx — página /recursos: búsqueda, filtro por tipo, tabla con badge de tipo, costo, "creado por", editar/eliminar con confirmación Nuevo — src/components/ui/sheet.tsx: componente shadcn Sheet (no existía), construido sobre @radix-ui/react-dialog que ya estaba instalado — no agregué dependencias nuevas. ResourcesPanel.tsx rediseñado: ahora solo muestra recursos usados en el proceso actual (derivado de assignedResources), con conteo de actividades por recurso. CRUD completo se mudó a /recursos; el panel solo permite "Nuevo" (catálogo global) y tiene el link "Ver catálogo completo". Router: ruta /recursos registrada. ActivityPanel no se tocó, como indicaba el scope.
This commit is contained in:
202
sprints/sprint-4/ETAPA_7_PROMPT.md
Normal file
202
sprints/sprint-4/ETAPA_7_PROMPT.md
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
# Sprint 4 — Etapa 7: ResourcesPanel — UX de catálogo global
|
||||||
|
|
||||||
|
## Contexto
|
||||||
|
|
||||||
|
InQ ROI — React 18 + Vite + TypeScript + TanStack Router + Zustand + shadcn/ui + Tailwind.
|
||||||
|
Etapas 1–6 completadas. El backend de recursos ya es global desde Etapa 3: `resource-repo.ts` hace `getAll()` sin filtro, el upsert no envía `processId`, y la tabla `resources` en Supabase no tiene columna `process_id`.
|
||||||
|
|
||||||
|
Lo que falta es UX: el `ResourcesPanel` todavía muestra todos los recursos sin filtrar (con el header "Recursos del proceso" que ya no corresponde) y no existe página de catálogo.
|
||||||
|
|
||||||
|
**Este prompt tiene un mockup aprobado como contrato visual. Claude Code lo replica sin interpretar libremente.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Mockup aprobado — descripción exacta
|
||||||
|
|
||||||
|
### Vista 1: ResourcesPanel en workspace (panel lateral derecho)
|
||||||
|
|
||||||
|
**Header del panel:**
|
||||||
|
- Título: "Recursos" (no "del proceso")
|
||||||
|
- Botón naranja `#F59845` con ícono `+` y texto "Nuevo" — abre un Sheet/Dialog para crear recurso en el catálogo global
|
||||||
|
|
||||||
|
**Cuerpo del panel:**
|
||||||
|
- Subheader: "Usados en este proceso" + cuenta `(N)`
|
||||||
|
- Lista de recursos que tienen al menos una asignación en las actividades del proceso actual
|
||||||
|
- Cada ítem: avatar circular con ícono del tipo (ver iconos y colores más abajo) + nombre del recurso + costo/hora a la derecha
|
||||||
|
- Segunda línea: tipo en texto + "N actividades" (contar cuántas actividades de este proceso usan este recurso)
|
||||||
|
- Si no hay recursos asignados: estado vacío con mensaje "Ningún recurso asignado a actividades aún"
|
||||||
|
|
||||||
|
**Footer del panel:**
|
||||||
|
- Texto de contexto: "Recursos del catálogo global del equipo"
|
||||||
|
- Botón secundario ancho completo: ícono `external-link` + "Ver catálogo completo" → navega a `/recursos`
|
||||||
|
|
||||||
|
**La lista de recursos "usados en este proceso" se deriva así:**
|
||||||
|
```typescript
|
||||||
|
const usedResourceIds = new Set(
|
||||||
|
currentProcess.activities
|
||||||
|
.flatMap(a => a.assignedResources ?? [])
|
||||||
|
.map(ar => ar.resourceId)
|
||||||
|
)
|
||||||
|
const usedResources = resources.filter(r => usedResourceIds.has(r.id))
|
||||||
|
const usageCount = (resourceId: string) =>
|
||||||
|
currentProcess.activities.filter(
|
||||||
|
a => (a.assignedResources ?? []).some(ar => ar.resourceId === resourceId)
|
||||||
|
).length
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Vista 2: Página catálogo `/recursos`
|
||||||
|
|
||||||
|
**Header de la página:**
|
||||||
|
- Título: "Catálogo de recursos"
|
||||||
|
- Subtítulo con ícono de equipo: "Compartido con todo el equipo · N recursos"
|
||||||
|
- Botón naranja `#F59845`: "+ Nuevo recurso" → abre Sheet/Dialog de creación
|
||||||
|
|
||||||
|
**Barra de herramientas:**
|
||||||
|
- Input de búsqueda (filtra por nombre en tiempo real, client-side)
|
||||||
|
- Select de tipo (filtra por tipo: "Todos los tipos" + los 5 tipos)
|
||||||
|
|
||||||
|
**Tabla:**
|
||||||
|
| Recurso | Tipo | Costo/h | Creado por | Acciones |
|
||||||
|
|---------|------|---------|-----------|---------|
|
||||||
|
| [avatar + nombre] | [badge] | [mono] | [nombre] | [edit] [delete] |
|
||||||
|
|
||||||
|
- Botones de acción: editar (abre Sheet con form pre-poblado) y eliminar (con confirmación)
|
||||||
|
- "Creado por" se resuelve desde el JOIN con `users` (igual que en LibraryPage — mismo patrón de Etapa 6)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tipos de recurso — valores definitivos
|
||||||
|
|
||||||
|
**Los únicos valores válidos son:** `person | role | system | equipment | supply`
|
||||||
|
|
||||||
|
Estos son los valores del schema real (migración 005). Actualizar `ResourceType` en `domain/types.ts`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export type ResourceType = 'person' | 'role' | 'system' | 'equipment' | 'supply'
|
||||||
|
```
|
||||||
|
|
||||||
|
**Labels en español para la UI:**
|
||||||
|
```typescript
|
||||||
|
const RESOURCE_TYPE_LABELS: Record<ResourceType, string> = {
|
||||||
|
person: 'Persona',
|
||||||
|
role: 'Rol',
|
||||||
|
system: 'Sistema',
|
||||||
|
equipment: 'Equipo',
|
||||||
|
supply: 'Insumo',
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Íconos y colores por tipo (Lucide React / Tabler):**
|
||||||
|
```typescript
|
||||||
|
// Usando Lucide React (ya en el stack):
|
||||||
|
person: User — fondo #FEF3E7, ícono #F59845
|
||||||
|
role: Briefcase — fondo #FEF3E7, ícono #D97706
|
||||||
|
system: Server — fondo #EEF2FF, ícono #6366F1
|
||||||
|
equipment: Wrench — fondo #F0FDF4, ícono #16A34A
|
||||||
|
supply: Package — fondo #F1F5F9, ícono #64748B
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Alcance estricto — qué entra en esta etapa
|
||||||
|
|
||||||
|
### 1. Limpieza de tipos
|
||||||
|
|
||||||
|
En `src/domain/types.ts`:
|
||||||
|
- Actualizar `ResourceType` a los 5 valores definitivos (ver arriba)
|
||||||
|
- Eliminar `processId?: UUID` de la interfaz `Resource` — es un campo vestigial sin uso. Ya no se mapea ni se envía
|
||||||
|
|
||||||
|
En `src/persistence/supabase/resource-repo.ts`:
|
||||||
|
- Eliminar cualquier referencia a `processId` si existe en `fromRow` o `toRow`
|
||||||
|
|
||||||
|
### 2. Página de catálogo — nueva ruta `/recursos`
|
||||||
|
|
||||||
|
Crear `src/features/resources/ResourceCatalogPage.tsx` con:
|
||||||
|
- Header + búsqueda + filtro por tipo + tabla (ver mockup)
|
||||||
|
- Sheet/Dialog compartido para crear y editar recursos (reutilizar el mismo componente para ambas acciones)
|
||||||
|
- Confirmación antes de eliminar (no eliminar recursos que están asignados a actividades — mostrar error explicativo si hay asignaciones activas)
|
||||||
|
|
||||||
|
**JOIN con users para "Creado por"** (mismo patrón que Etapa 6):
|
||||||
|
```typescript
|
||||||
|
// En resource-repo.ts, método getAll():
|
||||||
|
const { data } = await supabase
|
||||||
|
.from('resources')
|
||||||
|
.select('*, creator:users!created_by(id, name)')
|
||||||
|
.order('name', { ascending: true })
|
||||||
|
```
|
||||||
|
|
||||||
|
Agregar tipo extendido `ResourceWithCreator` en `resource-repo.ts` (igual que `ProcessWithUserNames` en Etapa 6).
|
||||||
|
|
||||||
|
**Ruta nueva en TanStack Router:** `src/router.tsx` o donde estén definidas las rutas.
|
||||||
|
|
||||||
|
### 3. Navegación al catálogo
|
||||||
|
|
||||||
|
Agregar acceso a `/recursos` desde la app. Opciones (elegir la más limpia según la estructura de nav existente):
|
||||||
|
- Link en AppHeader junto al logo/título
|
||||||
|
- O link desde el botón "Ver catálogo completo" del ResourcesPanel (este ya está en el mockup)
|
||||||
|
- No agregar ítem de navegación lateral si no existe nav lateral — el acceso desde el panel es suficiente para Sprint 4
|
||||||
|
|
||||||
|
### 4. ResourcesPanel — redesign
|
||||||
|
|
||||||
|
Reemplazar el cuerpo actual de `src/features/workspace/ResourcesPanel.tsx` según el mockup aprobado:
|
||||||
|
- Lista filtrada "usados en este proceso" (ver derivación arriba)
|
||||||
|
- Botón "Nuevo" → Sheet/Dialog de creación (el mismo componente de la página de catálogo)
|
||||||
|
- Link "Ver catálogo completo" → navega a `/recursos`
|
||||||
|
- El CRUD completo (editar, eliminar, listar todos) vive en `/recursos`, no en el panel del workspace
|
||||||
|
|
||||||
|
### 5. ActivityPanel — sin cambios
|
||||||
|
|
||||||
|
El dropdown de asignación de recursos ya funciona correctamente con el catálogo global. No tocar.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Qué NO entra en esta etapa
|
||||||
|
|
||||||
|
- Gestión de grupos de usuarios
|
||||||
|
- Permisos por recurso (los recursos son siempre visibles para todos los autenticados)
|
||||||
|
- Import/export de catálogo
|
||||||
|
- Historial de uso de un recurso (cuántos procesos lo usan)
|
||||||
|
- Cambios en PDF, simulación o workspace salvo ResourcesPanel
|
||||||
|
- Tests de integración de auth → Etapa 8
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Orden de ejecución
|
||||||
|
|
||||||
|
1. Actualizar `ResourceType` en `domain/types.ts` + eliminar `processId?` de `Resource`
|
||||||
|
2. Limpiar `resource-repo.ts` (eliminar processId, agregar JOIN con users)
|
||||||
|
3. Crear `src/features/resources/ResourceCatalogPage.tsx`
|
||||||
|
4. Registrar la ruta `/recursos` en el router
|
||||||
|
5. Redesign de `ResourcesPanel.tsx` (mockup como ground truth)
|
||||||
|
6. `npm run build` — debe pasar sin errores TypeScript
|
||||||
|
7. `npm run test` — debe mantener 521+ tests verdes
|
||||||
|
8. Commit: `feat(sprint-4/etapa-7): catálogo global de recursos + redesign ResourcesPanel`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Criterios de validación
|
||||||
|
|
||||||
|
- [ ] `ResourceType` tiene exactamente `'person' | 'role' | 'system' | 'equipment' | 'supply'`
|
||||||
|
- [ ] `Resource` no tiene campo `processId`
|
||||||
|
- [ ] Ruta `/recursos` existe y carga la página de catálogo
|
||||||
|
- [ ] Tabla del catálogo muestra nombre, tipo (badge con color), costo/h, creado por
|
||||||
|
- [ ] Búsqueda client-side funciona por nombre
|
||||||
|
- [ ] Filtro por tipo funciona
|
||||||
|
- [ ] Crear recurso desde catálogo funciona (guarda en Supabase, aparece en la lista)
|
||||||
|
- [ ] Editar recurso funciona
|
||||||
|
- [ ] Eliminar recurso funciona (con confirmación)
|
||||||
|
- [ ] ResourcesPanel en workspace muestra SOLO recursos usados en el proceso actual
|
||||||
|
- [ ] ResourcesPanel "Ver catálogo completo" navega a `/recursos`
|
||||||
|
- [ ] ResourcesPanel "Nuevo" crea en el catálogo global
|
||||||
|
- [ ] ActivityPanel sigue funcionando (asignación de recursos sin cambios)
|
||||||
|
- [ ] Build limpio sin errores TypeScript
|
||||||
|
- [ ] 521+ tests verdes
|
||||||
|
- [ ] **Validación visual del director** — ver ResourcesPanel con lista filtrada y página `/recursos` con tabla antes del OK formal
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Nota: Sheet vs Modal para el form de recurso
|
||||||
|
|
||||||
|
Usar `Sheet` de shadcn/ui (panel lateral deslizante) en lugar de `Dialog` (modal centrado) — es más consistente con el contexto del workspace donde el espacio es lateral. El Sheet se abre desde ambos lugares: botón "Nuevo" del ResourcesPanel y botón "+ Nuevo recurso" de la página de catálogo. Reutilizar el mismo componente `ResourceFormSheet.tsx` en ambos contextos.
|
||||||
110
src/components/ui/sheet.tsx
Normal file
110
src/components/ui/sheet.tsx
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
import * as SheetPrimitive from '@radix-ui/react-dialog'
|
||||||
|
import { cva, type VariantProps } from 'class-variance-authority'
|
||||||
|
import { X } from 'lucide-react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const Sheet = SheetPrimitive.Root
|
||||||
|
const SheetTrigger = SheetPrimitive.Trigger
|
||||||
|
const SheetClose = SheetPrimitive.Close
|
||||||
|
const SheetPortal = SheetPrimitive.Portal
|
||||||
|
|
||||||
|
const SheetOverlay = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SheetPrimitive.Overlay>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Overlay>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SheetPrimitive.Overlay
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'fixed inset-0 z-50 bg-black/40 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName
|
||||||
|
|
||||||
|
const sheetVariants = cva(
|
||||||
|
'fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500',
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
side: {
|
||||||
|
right:
|
||||||
|
'inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm',
|
||||||
|
left:
|
||||||
|
'inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: { side: 'right' },
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
interface SheetContentProps
|
||||||
|
extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
|
||||||
|
VariantProps<typeof sheetVariants> {}
|
||||||
|
|
||||||
|
const SheetContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SheetPrimitive.Content>,
|
||||||
|
SheetContentProps
|
||||||
|
>(({ side = 'right', className, children, ...props }, ref) => (
|
||||||
|
<SheetPortal>
|
||||||
|
<SheetOverlay />
|
||||||
|
<SheetPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(sheetVariants({ side }), className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:outline-none">
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
<span className="sr-only">Cerrar</span>
|
||||||
|
</SheetPrimitive.Close>
|
||||||
|
</SheetPrimitive.Content>
|
||||||
|
</SheetPortal>
|
||||||
|
))
|
||||||
|
SheetContent.displayName = SheetPrimitive.Content.displayName
|
||||||
|
|
||||||
|
const SheetHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div className={cn('flex flex-col space-y-2 text-left', className)} {...props} />
|
||||||
|
)
|
||||||
|
SheetHeader.displayName = 'SheetHeader'
|
||||||
|
|
||||||
|
const SheetFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div className={cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:gap-2', className)} {...props} />
|
||||||
|
)
|
||||||
|
SheetFooter.displayName = 'SheetFooter'
|
||||||
|
|
||||||
|
const SheetTitle = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SheetPrimitive.Title>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Title>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SheetPrimitive.Title
|
||||||
|
ref={ref}
|
||||||
|
className={cn('text-base font-semibold text-foreground', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
SheetTitle.displayName = SheetPrimitive.Title.displayName
|
||||||
|
|
||||||
|
const SheetDescription = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SheetPrimitive.Description>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Description>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SheetPrimitive.Description
|
||||||
|
ref={ref}
|
||||||
|
className={cn('text-sm text-muted-foreground', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
SheetDescription.displayName = SheetPrimitive.Description.displayName
|
||||||
|
|
||||||
|
export {
|
||||||
|
Sheet,
|
||||||
|
SheetTrigger,
|
||||||
|
SheetClose,
|
||||||
|
SheetContent,
|
||||||
|
SheetHeader,
|
||||||
|
SheetFooter,
|
||||||
|
SheetTitle,
|
||||||
|
SheetDescription,
|
||||||
|
}
|
||||||
@@ -65,7 +65,6 @@ export type ResourceType = 'role' | 'person' | 'system' | 'equipment' | 'supply'
|
|||||||
|
|
||||||
export interface Resource {
|
export interface Resource {
|
||||||
id: UUID
|
id: UUID
|
||||||
processId?: UUID // opcional — recursos son catálogo global desde Etapa 3
|
|
||||||
name: string
|
name: string
|
||||||
type: ResourceType
|
type: ResourceType
|
||||||
costPerHour: number
|
costPerHour: number
|
||||||
|
|||||||
206
src/features/resources/ResourceCatalogPage.tsx
Normal file
206
src/features/resources/ResourceCatalogPage.tsx
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { Users2, Search, Plus, Pencil, Trash2 } from 'lucide-react'
|
||||||
|
import { AppHeader } from '@/components/AppHeader'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||||
|
import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from '@/components/ui/table'
|
||||||
|
import { useAuth } from '@/auth/AuthContext'
|
||||||
|
import { supabaseResourceRepo, type ResourceWithCreator } from '@/persistence/supabase/resource-repo'
|
||||||
|
import { formatCurrency } from '@/lib/format'
|
||||||
|
import { RESOURCE_TYPE_LABELS, RESOURCE_TYPE_STYLES, ResourceTypeAvatar } from './resource-display'
|
||||||
|
import { ResourceFormSheet } from './ResourceFormSheet'
|
||||||
|
import type { Resource, ResourceType } from '@/domain/types'
|
||||||
|
|
||||||
|
export function ResourceCatalogPage() {
|
||||||
|
const { user } = useAuth()
|
||||||
|
const [resources, setResources] = useState<ResourceWithCreator[]>([])
|
||||||
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
|
const [search, setSearch] = useState('')
|
||||||
|
const [typeFilter, setTypeFilter] = useState<ResourceType | 'all'>('all')
|
||||||
|
const [sheetOpen, setSheetOpen] = useState(false)
|
||||||
|
const [editing, setEditing] = useState<Resource | undefined>(undefined)
|
||||||
|
const [deleteError, setDeleteError] = useState<string | null>(null)
|
||||||
|
const [confirmingDeleteId, setConfirmingDeleteId] = useState<string | null>(null)
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
setIsLoading(true)
|
||||||
|
try {
|
||||||
|
const data = await supabaseResourceRepo.getAllWithCreator()
|
||||||
|
setResources(data)
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const filtered = resources.filter((r) => {
|
||||||
|
const matchesName = r.name.toLowerCase().includes(search.toLowerCase())
|
||||||
|
const matchesType = typeFilter === 'all' || r.type === typeFilter
|
||||||
|
return matchesName && matchesType
|
||||||
|
})
|
||||||
|
|
||||||
|
function startCreate() {
|
||||||
|
setEditing(undefined)
|
||||||
|
setSheetOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
function startEdit(resource: Resource) {
|
||||||
|
setEditing(resource)
|
||||||
|
setSheetOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSave(resource: Resource) {
|
||||||
|
await supabaseResourceRepo.save(resource, user!.id)
|
||||||
|
await load()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(resourceId: string) {
|
||||||
|
setDeleteError(null)
|
||||||
|
try {
|
||||||
|
await supabaseResourceRepo.delete(resourceId)
|
||||||
|
setConfirmingDeleteId(null)
|
||||||
|
await load()
|
||||||
|
} catch (err) {
|
||||||
|
setDeleteError(err instanceof Error ? err.message : 'No se pudo eliminar el recurso')
|
||||||
|
setConfirmingDeleteId(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-background">
|
||||||
|
<AppHeader />
|
||||||
|
<div className="max-w-4xl mx-auto px-6 py-8">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-start justify-between mb-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-[18px] font-medium">Catálogo de recursos</h1>
|
||||||
|
<div className="flex items-center gap-1.5 mt-0.5">
|
||||||
|
<Users2 className="h-3.5 w-3.5 text-muted-foreground" />
|
||||||
|
<p className="text-[12px] text-muted-foreground">
|
||||||
|
Compartido con todo el equipo · {resources.length} recurso{resources.length !== 1 ? 's' : ''}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button onClick={startCreate} className="gap-2 text-white" style={{ background: '#F59845' }}>
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
Nuevo recurso
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{deleteError && (
|
||||||
|
<div className="mb-4 px-3 py-2 rounded-lg text-[12px] border border-destructive/30 bg-destructive/10 text-destructive">
|
||||||
|
{deleteError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Toolbar */}
|
||||||
|
<div className="flex items-center gap-2 mb-4">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
placeholder="Buscar recurso por nombre..."
|
||||||
|
className="pl-9"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Select value={typeFilter} onValueChange={(v) => setTypeFilter(v as ResourceType | 'all')}>
|
||||||
|
<SelectTrigger className="w-[180px]">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">Todos los tipos</SelectItem>
|
||||||
|
{(Object.entries(RESOURCE_TYPE_LABELS) as [ResourceType, string][]).map(([value, label]) => (
|
||||||
|
<SelectItem key={value} value={value}>{label}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tabla */}
|
||||||
|
{!isLoading && filtered.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-16 gap-2">
|
||||||
|
<p className="text-[13px] font-medium text-secondary-foreground">
|
||||||
|
{resources.length === 0 ? 'Sin recursos en el catálogo' : 'Sin resultados para esa búsqueda'}
|
||||||
|
</p>
|
||||||
|
<p className="text-[13px] text-muted-foreground">
|
||||||
|
{resources.length === 0 && 'Creá el primer recurso del equipo'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Recurso</TableHead>
|
||||||
|
<TableHead>Tipo</TableHead>
|
||||||
|
<TableHead>Costo/h</TableHead>
|
||||||
|
<TableHead>Creado por</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]">{formatCurrency(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">
|
||||||
|
<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>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ResourceFormSheet
|
||||||
|
open={sheetOpen}
|
||||||
|
onOpenChange={setSheetOpen}
|
||||||
|
resource={editing}
|
||||||
|
onSave={handleSave}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
112
src/features/resources/ResourceFormSheet.tsx
Normal file
112
src/features/resources/ResourceFormSheet.tsx
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { v4 as uuidv4 } from 'uuid'
|
||||||
|
import { Check } from 'lucide-react'
|
||||||
|
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetFooter } from '@/components/ui/sheet'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||||
|
import { RESOURCE_TYPE_LABELS } from './resource-display'
|
||||||
|
import type { Resource, ResourceType } from '@/domain/types'
|
||||||
|
|
||||||
|
interface ResourceFormState {
|
||||||
|
name: string
|
||||||
|
type: ResourceType
|
||||||
|
costPerHour: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const EMPTY_FORM: ResourceFormState = { name: '', type: 'role', costPerHour: '' }
|
||||||
|
|
||||||
|
interface ResourceFormSheetProps {
|
||||||
|
open: boolean
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
resource?: Resource
|
||||||
|
onSave: (resource: Resource) => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ResourceFormSheet({ open, onOpenChange, resource, onSave }: ResourceFormSheetProps) {
|
||||||
|
const [form, setForm] = useState<ResourceFormState>(EMPTY_FORM)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setForm(
|
||||||
|
resource
|
||||||
|
? { name: resource.name, type: resource.type, costPerHour: resource.costPerHour.toString() }
|
||||||
|
: EMPTY_FORM
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}, [open, resource])
|
||||||
|
|
||||||
|
async function handleSubmit() {
|
||||||
|
if (!form.name.trim()) return
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
await onSave({
|
||||||
|
id: resource?.id ?? uuidv4(),
|
||||||
|
name: form.name.trim(),
|
||||||
|
type: form.type,
|
||||||
|
costPerHour: parseFloat(form.costPerHour) || 0,
|
||||||
|
})
|
||||||
|
onOpenChange(false)
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||||
|
<SheetContent>
|
||||||
|
<SheetHeader>
|
||||||
|
<SheetTitle>{resource ? 'Editar recurso' : 'Nuevo recurso'}</SheetTitle>
|
||||||
|
</SheetHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4 mt-6">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs">Nombre</Label>
|
||||||
|
<Input
|
||||||
|
autoFocus
|
||||||
|
placeholder="ej. Analista Senior"
|
||||||
|
value={form.name}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs">Tipo</Label>
|
||||||
|
<Select value={form.type} onValueChange={(v) => setForm((f) => ({ ...f, type: v as ResourceType }))}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{(Object.entries(RESOURCE_TYPE_LABELS) as [ResourceType, string][]).map(([value, label]) => (
|
||||||
|
<SelectItem key={value} value={value}>{label}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs">Costo por hora</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
step={5}
|
||||||
|
placeholder="0.00"
|
||||||
|
value={form.costPerHour}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, costPerHour: e.target.value }))}
|
||||||
|
className="font-mono"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SheetFooter className="mt-6">
|
||||||
|
<Button onClick={handleSubmit} disabled={!form.name.trim() || saving} className="gap-1.5">
|
||||||
|
<Check className="h-4 w-4" />
|
||||||
|
{resource ? 'Actualizar' : 'Crear'}
|
||||||
|
</Button>
|
||||||
|
</SheetFooter>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
)
|
||||||
|
}
|
||||||
36
src/features/resources/resource-display.tsx
Normal file
36
src/features/resources/resource-display.tsx
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { User, Briefcase, Server, Wrench, Package, type LucideIcon } from 'lucide-react'
|
||||||
|
import type { ResourceType } from '@/domain/types'
|
||||||
|
|
||||||
|
export const RESOURCE_TYPE_LABELS: Record<ResourceType, string> = {
|
||||||
|
person: 'Persona',
|
||||||
|
role: 'Rol',
|
||||||
|
system: 'Sistema',
|
||||||
|
equipment: 'Equipo',
|
||||||
|
supply: 'Insumo',
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ResourceTypeStyle {
|
||||||
|
icon: LucideIcon
|
||||||
|
bg: string
|
||||||
|
fg: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const RESOURCE_TYPE_STYLES: Record<ResourceType, ResourceTypeStyle> = {
|
||||||
|
person: { icon: User, bg: '#FEF3E7', fg: '#F59845' },
|
||||||
|
role: { icon: Briefcase, bg: '#FEF3E7', fg: '#D97706' },
|
||||||
|
system: { icon: Server, bg: '#EEF2FF', fg: '#6366F1' },
|
||||||
|
equipment: { icon: Wrench, bg: '#F0FDF4', fg: '#16A34A' },
|
||||||
|
supply: { icon: Package, bg: '#F1F5F9', fg: '#64748B' },
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ResourceTypeAvatar({ type, size = 32 }: { type: ResourceType; size?: number }) {
|
||||||
|
const { icon: Icon, bg, fg } = RESOURCE_TYPE_STYLES[type]
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex items-center justify-center rounded-full flex-shrink-0"
|
||||||
|
style={{ width: size, height: size, background: bg }}
|
||||||
|
>
|
||||||
|
<Icon style={{ color: fg, width: size * 0.55, height: size * 0.55 }} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,202 +1,97 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { PlusCircle, Pencil, Trash2, Check, X } from 'lucide-react'
|
import { useNavigate } from '@tanstack/react-router'
|
||||||
import { v4 as uuidv4 } from 'uuid'
|
import { Plus, ExternalLink } from 'lucide-react'
|
||||||
import { Label } from '@/components/ui/label'
|
|
||||||
import { Input } from '@/components/ui/input'
|
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
|
||||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||||
import { useProcessStore } from '@/store/process-store'
|
import { useProcessStore } from '@/store/process-store'
|
||||||
import { useAuth } from '@/auth/AuthContext'
|
import { useAuth } from '@/auth/AuthContext'
|
||||||
import { formatCurrency } from '@/lib/format'
|
import { formatCurrency } from '@/lib/format'
|
||||||
import type { Resource, ResourceType } from '@/domain/types'
|
import { RESOURCE_TYPE_LABELS, ResourceTypeAvatar } from '@/features/resources/resource-display'
|
||||||
|
import { ResourceFormSheet } from '@/features/resources/ResourceFormSheet'
|
||||||
const RESOURCE_TYPE_LABELS: Record<ResourceType, string> = {
|
import type { Resource } from '@/domain/types'
|
||||||
role: 'Rol',
|
|
||||||
person: 'Persona',
|
|
||||||
system: 'Sistema',
|
|
||||||
equipment: 'Equipo',
|
|
||||||
supply: 'Insumo',
|
|
||||||
}
|
|
||||||
|
|
||||||
const RESOURCE_TYPE_COLORS: Record<ResourceType, string> = {
|
|
||||||
role: 'bg-slate-100 text-slate-600',
|
|
||||||
person: 'bg-green-100 text-green-700',
|
|
||||||
system: 'bg-purple-100 text-purple-700',
|
|
||||||
equipment: 'bg-amber-100 text-amber-700',
|
|
||||||
supply: 'bg-slate-100 text-slate-700',
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ResourceFormState {
|
|
||||||
name: string
|
|
||||||
type: ResourceType
|
|
||||||
costPerHour: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const EMPTY_FORM: ResourceFormState = { name: '', type: 'role', costPerHour: '' }
|
|
||||||
|
|
||||||
export function ResourcesPanel() {
|
export function ResourcesPanel() {
|
||||||
const { currentProcess, resources, addResource, updateResource, deleteResource } = useProcessStore()
|
const { activities, resources, addResource } = useProcessStore()
|
||||||
const { user } = useAuth()
|
const { user } = useAuth()
|
||||||
const [showForm, setShowForm] = useState(false)
|
const navigate = useNavigate()
|
||||||
const [editingId, setEditingId] = useState<string | null>(null)
|
const [sheetOpen, setSheetOpen] = useState(false)
|
||||||
const [form, setForm] = useState<ResourceFormState>(EMPTY_FORM)
|
|
||||||
|
|
||||||
function startAdd() {
|
const usedResourceIds = new Set(
|
||||||
setForm(EMPTY_FORM)
|
activities.flatMap((a) => a.assignedResources ?? []).map((ar) => ar.resourceId)
|
||||||
setEditingId(null)
|
)
|
||||||
setShowForm(true)
|
const usedResources = resources.filter((r) => usedResourceIds.has(r.id))
|
||||||
}
|
const usageCount = (resourceId: string) =>
|
||||||
|
activities.filter((a) => (a.assignedResources ?? []).some((ar) => ar.resourceId === resourceId)).length
|
||||||
|
|
||||||
function startEdit(resource: Resource) {
|
async function handleSave(resource: Resource) {
|
||||||
setForm({ name: resource.name, type: resource.type, costPerHour: resource.costPerHour.toString() })
|
await addResource(resource, user!.id)
|
||||||
setEditingId(resource.id)
|
|
||||||
setShowForm(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
function cancelForm() {
|
|
||||||
setShowForm(false)
|
|
||||||
setEditingId(null)
|
|
||||||
setForm(EMPTY_FORM)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleSubmit() {
|
|
||||||
if (!form.name.trim() || !currentProcess) return
|
|
||||||
const costPerHour = parseFloat(form.costPerHour) || 0
|
|
||||||
|
|
||||||
if (editingId) {
|
|
||||||
const existing = resources.find((r) => r.id === editingId)
|
|
||||||
if (!existing) return
|
|
||||||
await updateResource({ ...existing, name: form.name.trim(), type: form.type, costPerHour }, user!.id)
|
|
||||||
} else {
|
|
||||||
const newResource: Resource = {
|
|
||||||
id: uuidv4(),
|
|
||||||
name: form.name.trim(),
|
|
||||||
type: form.type,
|
|
||||||
costPerHour,
|
|
||||||
}
|
|
||||||
await addResource(newResource, user!.id)
|
|
||||||
}
|
|
||||||
cancelForm()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollArea className="h-full">
|
<div className="h-full flex flex-col">
|
||||||
<div className="p-4 space-y-4">
|
{/* Header */}
|
||||||
{/* Header */}
|
<div className="flex items-center justify-between p-4 pb-3 border-b border-slate-100">
|
||||||
<div className="flex items-center justify-between">
|
<p className="text-xs font-medium text-slate-700">Recursos</p>
|
||||||
<div>
|
<Button
|
||||||
<p className="text-xs font-medium text-slate-700">Recursos del proceso</p>
|
size="sm"
|
||||||
<p className="text-xs text-slate-400">{resources.length} definido{resources.length !== 1 ? 's' : ''}</p>
|
onClick={() => setSheetOpen(true)}
|
||||||
</div>
|
className="h-7 text-xs gap-1 text-white"
|
||||||
{!showForm && (
|
style={{ background: '#F59845' }}
|
||||||
<Button size="sm" variant="outline" onClick={startAdd} className="h-7 text-xs gap-1">
|
>
|
||||||
<PlusCircle className="h-3.5 w-3.5" />
|
<Plus className="h-3.5 w-3.5" />
|
||||||
Nuevo
|
Nuevo
|
||||||
</Button>
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Cuerpo */}
|
||||||
|
<ScrollArea className="flex-1">
|
||||||
|
<div className="p-4 space-y-3">
|
||||||
|
<p className="text-xs font-medium text-slate-500">
|
||||||
|
Usados en este proceso <span className="text-slate-400">({usedResources.length})</span>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{usedResources.length === 0 ? (
|
||||||
|
<div className="text-center py-8">
|
||||||
|
<p className="text-xs text-slate-400">Ningún recurso asignado a actividades aún</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{usedResources.map((resource) => (
|
||||||
|
<div
|
||||||
|
key={resource.id}
|
||||||
|
className="flex items-center gap-2.5 p-3 rounded-lg border border-slate-100 bg-white"
|
||||||
|
>
|
||||||
|
<ResourceTypeAvatar type={resource.type} size={32} />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<p className="text-xs font-medium text-slate-800 truncate">{resource.name}</p>
|
||||||
|
<p className="text-xs text-slate-500 font-mono shrink-0">{formatCurrency(resource.costPerHour)}/h</p>
|
||||||
|
</div>
|
||||||
|
<p className="text-[11px] text-slate-400 mt-0.5">
|
||||||
|
{RESOURCE_TYPE_LABELS[resource.type]} · {usageCount(resource.id)} actividad{usageCount(resource.id) !== 1 ? 'es' : ''}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
|
||||||
{/* Formulario */}
|
{/* Footer */}
|
||||||
{showForm && (
|
<div className="p-4 pt-3 border-t border-slate-100 space-y-2">
|
||||||
<div className="border border-primary/20 bg-primary/5 rounded-lg p-3 space-y-3">
|
<p className="text-[11px] text-slate-400 text-center">Recursos del catálogo global del equipo</p>
|
||||||
<p className="text-xs font-semibold text-slate-700">{editingId ? 'Editar recurso' : 'Nuevo recurso'}</p>
|
<Button
|
||||||
|
variant="outline"
|
||||||
<div className="space-y-1.5">
|
className="w-full h-8 text-xs gap-1.5"
|
||||||
<Label className="text-xs">Nombre</Label>
|
onClick={() => navigate({ to: '/recursos' })}
|
||||||
<Input
|
>
|
||||||
autoFocus
|
<ExternalLink className="h-3.5 w-3.5" />
|
||||||
placeholder="ej. Analista Senior"
|
Ver catálogo completo
|
||||||
value={form.name}
|
</Button>
|
||||||
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
|
|
||||||
className="h-8 text-xs"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<Label className="text-xs">Tipo</Label>
|
|
||||||
<Select value={form.type} onValueChange={(v) => setForm((f) => ({ ...f, type: v as ResourceType }))}>
|
|
||||||
<SelectTrigger className="h-8 text-xs">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{(Object.entries(RESOURCE_TYPE_LABELS) as [ResourceType, string][]).map(([value, label]) => (
|
|
||||||
<SelectItem key={value} value={value} className="text-xs">{label}</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<Label className="text-xs">Costo por hora</Label>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
min={0}
|
|
||||||
step={5}
|
|
||||||
placeholder="0.00"
|
|
||||||
value={form.costPerHour}
|
|
||||||
onChange={(e) => setForm((f) => ({ ...f, costPerHour: e.target.value }))}
|
|
||||||
className="h-8 text-xs font-mono"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button size="sm" onClick={handleSubmit} disabled={!form.name.trim()} className="flex-1 h-7 text-xs gap-1">
|
|
||||||
<Check className="h-3.5 w-3.5" />
|
|
||||||
{editingId ? 'Actualizar' : 'Crear'}
|
|
||||||
</Button>
|
|
||||||
<Button size="sm" variant="ghost" onClick={cancelForm} className="h-7 text-xs">
|
|
||||||
<X className="h-3.5 w-3.5" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Lista */}
|
|
||||||
{resources.length === 0 && !showForm ? (
|
|
||||||
<div className="text-center py-8">
|
|
||||||
<p className="text-xs text-slate-400 mb-2">Sin recursos definidos</p>
|
|
||||||
<p className="text-xs text-slate-300">Los recursos (roles, sistemas, equipos) se asignan a actividades para calcular su costo por hora</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-2">
|
|
||||||
{resources.map((resource) => (
|
|
||||||
<div
|
|
||||||
key={resource.id}
|
|
||||||
className="flex items-center justify-between p-3 rounded-lg border border-slate-100 hover:border-slate-200 bg-white group"
|
|
||||||
>
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<div className="flex items-center gap-2 mb-1">
|
|
||||||
<p className="text-xs font-medium text-slate-800 truncate">{resource.name}</p>
|
|
||||||
<span className={`shrink-0 text-[10px] font-medium px-1.5 py-0.5 rounded-full ${RESOURCE_TYPE_COLORS[resource.type]}`}>
|
|
||||||
{RESOURCE_TYPE_LABELS[resource.type]}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-slate-500 font-mono">{formatCurrency(resource.costPerHour)}/hora</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity shrink-0">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-6 w-6 text-slate-400 hover:text-slate-600"
|
|
||||||
onClick={() => startEdit(resource)}
|
|
||||||
>
|
|
||||||
<Pencil className="h-3 w-3" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-6 w-6 text-slate-400 hover:text-destructive"
|
|
||||||
onClick={() => deleteResource(resource.id, user!.id)}
|
|
||||||
>
|
|
||||||
<Trash2 className="h-3 w-3" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</ScrollArea>
|
|
||||||
|
<ResourceFormSheet open={sheetOpen} onOpenChange={setSheetOpen} onSave={handleSave} />
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,19 @@ function fromRow(row: Record<string, unknown>): Resource {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Solo para display — no es parte del dominio puro (mismo patrón que ProcessWithUserNames, Etapa 6)
|
||||||
|
export interface ResourceWithCreator extends Resource {
|
||||||
|
creatorName: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function fromRowWithCreator(row: Record<string, unknown>): ResourceWithCreator {
|
||||||
|
const creator = row.creator as { name: string | null } | null
|
||||||
|
return {
|
||||||
|
...fromRow(row),
|
||||||
|
creatorName: creator?.name ?? 'Desconocido',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export const supabaseResourceRepo = {
|
export const supabaseResourceRepo = {
|
||||||
async save(resource: Resource, userId: string): Promise<void> {
|
async save(resource: Resource, userId: string): Promise<void> {
|
||||||
const { error } = await supabase.from('resources').upsert({
|
const { error } = await supabase.from('resources').upsert({
|
||||||
@@ -33,7 +46,27 @@ export const supabaseResourceRepo = {
|
|||||||
return (data ?? []).map(fromRow)
|
return (data ?? []).map(fromRow)
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// JOIN con users para resolver "creado por" en una sola query (mismo patrón que Etapa 6)
|
||||||
|
async getAllWithCreator(): Promise<ResourceWithCreator[]> {
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('resources')
|
||||||
|
.select('*, creator:users!created_by(name)')
|
||||||
|
.order('name', { ascending: true })
|
||||||
|
if (error) throw error
|
||||||
|
return (data ?? []).map((row) => fromRowWithCreator(row as Record<string, unknown>))
|
||||||
|
},
|
||||||
|
|
||||||
|
// No elimina si el recurso tiene asignaciones activas en activity_resource_assignments
|
||||||
async delete(resourceId: string): Promise<void> {
|
async delete(resourceId: string): Promise<void> {
|
||||||
|
const { count, error: countError } = await supabase
|
||||||
|
.from('activity_resource_assignments')
|
||||||
|
.select('*', { count: 'exact', head: true })
|
||||||
|
.eq('resource_id', resourceId)
|
||||||
|
if (countError) throw countError
|
||||||
|
if ((count ?? 0) > 0) {
|
||||||
|
throw new Error('Este recurso está asignado a actividades — quitá las asignaciones antes de eliminarlo')
|
||||||
|
}
|
||||||
|
|
||||||
const { error } = await supabase.from('resources').delete().eq('id', resourceId)
|
const { error } = await supabase.from('resources').delete().eq('id', resourceId)
|
||||||
if (error) throw error
|
if (error) throw error
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { WorkspacePage } from '@/features/workspace/WorkspacePage'
|
|||||||
import { ImportPage } from '@/features/import/ImportPage'
|
import { ImportPage } from '@/features/import/ImportPage'
|
||||||
import { LibraryPage } from '@/features/library/LibraryPage'
|
import { LibraryPage } from '@/features/library/LibraryPage'
|
||||||
import { SettingsPage } from '@/features/settings/SettingsPage'
|
import { SettingsPage } from '@/features/settings/SettingsPage'
|
||||||
|
import { ResourceCatalogPage } from '@/features/resources/ResourceCatalogPage'
|
||||||
import { LoginPage } from '@/features/login/LoginPage'
|
import { LoginPage } from '@/features/login/LoginPage'
|
||||||
import { useAuth } from '@/auth/AuthContext'
|
import { useAuth } from '@/auth/AuthContext'
|
||||||
import { Loader2 } from 'lucide-react'
|
import { Loader2 } from 'lucide-react'
|
||||||
@@ -94,6 +95,12 @@ const settingsRoute = createRoute({
|
|||||||
component: SettingsPage,
|
component: SettingsPage,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const resourcesRoute = createRoute({
|
||||||
|
getParentRoute: () => rootRoute,
|
||||||
|
path: '/recursos',
|
||||||
|
component: ResourceCatalogPage,
|
||||||
|
})
|
||||||
|
|
||||||
const routeTree = rootRoute.addChildren([
|
const routeTree = rootRoute.addChildren([
|
||||||
loginRoute,
|
loginRoute,
|
||||||
indexRoute,
|
indexRoute,
|
||||||
@@ -101,6 +108,7 @@ const routeTree = rootRoute.addChildren([
|
|||||||
workspaceRoute,
|
workspaceRoute,
|
||||||
reportRoute,
|
reportRoute,
|
||||||
settingsRoute,
|
settingsRoute,
|
||||||
|
resourcesRoute,
|
||||||
])
|
])
|
||||||
|
|
||||||
export const router = createRouter({ routeTree })
|
export const router = createRouter({ routeTree })
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ const SINGLE_TASK_BPMN = `<?xml version="1.0" encoding="UTF-8"?>
|
|||||||
</definitions>`
|
</definitions>`
|
||||||
|
|
||||||
const makeResource = (id: string, costPerHour: number): Resource => ({
|
const makeResource = (id: string, costPerHour: number): Resource => ({
|
||||||
id, processId: 'proc1', name: `Recurso ${id}`, type: 'role', costPerHour,
|
id, name: `Recurso ${id}`, type: 'role', costPerHour,
|
||||||
})
|
})
|
||||||
|
|
||||||
const baseActivity = (overrides: Partial<Activity> = {}): Activity => ({
|
const baseActivity = (overrides: Partial<Activity> = {}): Activity => ({
|
||||||
|
|||||||
@@ -68,7 +68,6 @@ describe('parseBpmnXml', () => {
|
|||||||
describe('runSimulation', () => {
|
describe('runSimulation', () => {
|
||||||
const makeResource = (id: string, name: string, costPerHour: number): Resource => ({
|
const makeResource = (id: string, name: string, costPerHour: number): Resource => ({
|
||||||
id,
|
id,
|
||||||
processId: 'proc1',
|
|
||||||
name,
|
name,
|
||||||
type: 'role',
|
type: 'role',
|
||||||
costPerHour,
|
costPerHour,
|
||||||
@@ -398,7 +397,7 @@ describe('runSimulation — scenario automated', () => {
|
|||||||
|
|
||||||
describe('runSimulation — recursos con modelo utilizationMinutes + units', () => {
|
describe('runSimulation — recursos con modelo utilizationMinutes + units', () => {
|
||||||
const makeRes = (id: string, costPerHour: number): Resource => ({
|
const makeRes = (id: string, costPerHour: number): Resource => ({
|
||||||
id, processId: 'proc1', name: `Recurso ${id}`, type: 'role', costPerHour,
|
id, name: `Recurso ${id}`, type: 'role', costPerHour,
|
||||||
})
|
})
|
||||||
|
|
||||||
const makeAct = (
|
const makeAct = (
|
||||||
|
|||||||
@@ -258,7 +258,7 @@ describe('aggregateResourceCosts', () => {
|
|||||||
|
|
||||||
it('con recursos de tipo role: suma correctamente por tipo', () => {
|
it('con recursos de tipo role: suma correctamente por tipo', () => {
|
||||||
const resource: Resource = {
|
const resource: Resource = {
|
||||||
id: 'r1', processId: 'p1', name: 'Analista', type: 'role', costPerHour: 60,
|
id: 'r1', name: 'Analista', type: 'role', costPerHour: 60,
|
||||||
}
|
}
|
||||||
const perActivity = [{
|
const perActivity = [{
|
||||||
activityId: 'a1', bpmnElementId: 't1', activityName: 'Task', type: 'task' as const,
|
activityId: 'a1', bpmnElementId: 't1', activityName: 'Task', type: 'task' as const,
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ const mockSimNoResources: Simulation = {
|
|||||||
|
|
||||||
// Simulación CON recursos en una actividad
|
// Simulación CON recursos en una actividad
|
||||||
const mockResource: Resource = {
|
const mockResource: Resource = {
|
||||||
id: 'r1', processId: 'p1', name: 'Analista', type: 'role', costPerHour: 60,
|
id: 'r1', name: 'Analista', type: 'role', costPerHour: 60,
|
||||||
}
|
}
|
||||||
|
|
||||||
const mockActivity: Activity = {
|
const mockActivity: Activity = {
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ const mockDoc = {
|
|||||||
// ─── Fixtures ─────────────────────────────────────────────────────────────────
|
// ─── Fixtures ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const mockResource: Resource = {
|
const mockResource: Resource = {
|
||||||
id: 'r1', processId: 'p1', name: 'Analista Senior', type: 'role', costPerHour: 60,
|
id: 'r1', name: 'Analista Senior', type: 'role', costPerHour: 60,
|
||||||
}
|
}
|
||||||
|
|
||||||
const mockActivity: Activity = {
|
const mockActivity: Activity = {
|
||||||
|
|||||||
Reference in New Issue
Block a user