diff --git a/sprints/sprint-4/ETAPA_7_PROMPT.md b/sprints/sprint-4/ETAPA_7_PROMPT.md new file mode 100644 index 0000000..4734b83 --- /dev/null +++ b/sprints/sprint-4/ETAPA_7_PROMPT.md @@ -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 = { + 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. diff --git a/src/components/ui/sheet.tsx b/src/components/ui/sheet.tsx new file mode 100644 index 0000000..06605e3 --- /dev/null +++ b/src/components/ui/sheet.tsx @@ -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, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +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, + VariantProps {} + +const SheetContent = React.forwardRef< + React.ElementRef, + SheetContentProps +>(({ side = 'right', className, children, ...props }, ref) => ( + + + + {children} + + + Cerrar + + + +)) +SheetContent.displayName = SheetPrimitive.Content.displayName + +const SheetHeader = ({ className, ...props }: React.HTMLAttributes) => ( +
+) +SheetHeader.displayName = 'SheetHeader' + +const SheetFooter = ({ className, ...props }: React.HTMLAttributes) => ( +
+) +SheetFooter.displayName = 'SheetFooter' + +const SheetTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +SheetTitle.displayName = SheetPrimitive.Title.displayName + +const SheetDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +SheetDescription.displayName = SheetPrimitive.Description.displayName + +export { + Sheet, + SheetTrigger, + SheetClose, + SheetContent, + SheetHeader, + SheetFooter, + SheetTitle, + SheetDescription, +} diff --git a/src/domain/types.ts b/src/domain/types.ts index cd40dc0..7358eb4 100644 --- a/src/domain/types.ts +++ b/src/domain/types.ts @@ -65,7 +65,6 @@ export type ResourceType = 'role' | 'person' | 'system' | 'equipment' | 'supply' export interface Resource { id: UUID - processId?: UUID // opcional — recursos son catálogo global desde Etapa 3 name: string type: ResourceType costPerHour: number diff --git a/src/features/resources/ResourceCatalogPage.tsx b/src/features/resources/ResourceCatalogPage.tsx new file mode 100644 index 0000000..c9563b7 --- /dev/null +++ b/src/features/resources/ResourceCatalogPage.tsx @@ -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([]) + const [isLoading, setIsLoading] = useState(true) + const [search, setSearch] = useState('') + const [typeFilter, setTypeFilter] = useState('all') + const [sheetOpen, setSheetOpen] = useState(false) + const [editing, setEditing] = useState(undefined) + const [deleteError, setDeleteError] = useState(null) + const [confirmingDeleteId, setConfirmingDeleteId] = useState(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 ( +
+ +
+ {/* Header */} +
+
+

Catálogo de recursos

+
+ +

+ Compartido con todo el equipo · {resources.length} recurso{resources.length !== 1 ? 's' : ''} +

+
+
+ +
+ + {deleteError && ( +
+ {deleteError} +
+ )} + + {/* Toolbar */} +
+
+ + setSearch(e.target.value)} + placeholder="Buscar recurso por nombre..." + className="pl-9" + /> +
+ +
+ + {/* Tabla */} + {!isLoading && filtered.length === 0 ? ( +
+

+ {resources.length === 0 ? 'Sin recursos en el catálogo' : 'Sin resultados para esa búsqueda'} +

+

+ {resources.length === 0 && 'Creá el primer recurso del equipo'} +

+
+ ) : ( + + + + Recurso + Tipo + Costo/h + Creado por + Acciones + + + + {filtered.map((resource) => ( + + +
+ + {resource.name} +
+
+ + + {RESOURCE_TYPE_LABELS[resource.type]} + + + {formatCurrency(resource.costPerHour)} + {resource.creatorName} + + {confirmingDeleteId === resource.id ? ( +
+ ¿Eliminar? + + +
+ ) : ( +
+ + +
+ )} +
+
+ ))} +
+
+ )} +
+ + +
+ ) +} diff --git a/src/features/resources/ResourceFormSheet.tsx b/src/features/resources/ResourceFormSheet.tsx new file mode 100644 index 0000000..9ab6c6d --- /dev/null +++ b/src/features/resources/ResourceFormSheet.tsx @@ -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 +} + +export function ResourceFormSheet({ open, onOpenChange, resource, onSave }: ResourceFormSheetProps) { + const [form, setForm] = useState(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 ( + + + + {resource ? 'Editar recurso' : 'Nuevo recurso'} + + +
+
+ + setForm((f) => ({ ...f, name: e.target.value }))} + /> +
+ +
+ + +
+ +
+ + setForm((f) => ({ ...f, costPerHour: e.target.value }))} + className="font-mono" + /> +
+
+ + + + +
+
+ ) +} diff --git a/src/features/resources/resource-display.tsx b/src/features/resources/resource-display.tsx new file mode 100644 index 0000000..9c6bdc5 --- /dev/null +++ b/src/features/resources/resource-display.tsx @@ -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 = { + person: 'Persona', + role: 'Rol', + system: 'Sistema', + equipment: 'Equipo', + supply: 'Insumo', +} + +interface ResourceTypeStyle { + icon: LucideIcon + bg: string + fg: string +} + +export const RESOURCE_TYPE_STYLES: Record = { + 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 ( +
+ +
+ ) +} diff --git a/src/features/workspace/ResourcesPanel.tsx b/src/features/workspace/ResourcesPanel.tsx index 96bd2e6..3f3be56 100644 --- a/src/features/workspace/ResourcesPanel.tsx +++ b/src/features/workspace/ResourcesPanel.tsx @@ -1,202 +1,97 @@ import { useState } from 'react' -import { PlusCircle, Pencil, Trash2, Check, X } from 'lucide-react' -import { v4 as uuidv4 } from 'uuid' -import { Label } from '@/components/ui/label' -import { Input } from '@/components/ui/input' +import { useNavigate } from '@tanstack/react-router' +import { Plus, ExternalLink } from 'lucide-react' import { Button } from '@/components/ui/button' -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' import { ScrollArea } from '@/components/ui/scroll-area' import { useProcessStore } from '@/store/process-store' import { useAuth } from '@/auth/AuthContext' import { formatCurrency } from '@/lib/format' -import type { Resource, ResourceType } from '@/domain/types' - -const RESOURCE_TYPE_LABELS: Record = { - role: 'Rol', - person: 'Persona', - system: 'Sistema', - equipment: 'Equipo', - supply: 'Insumo', -} - -const RESOURCE_TYPE_COLORS: Record = { - 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: '' } +import { RESOURCE_TYPE_LABELS, ResourceTypeAvatar } from '@/features/resources/resource-display' +import { ResourceFormSheet } from '@/features/resources/ResourceFormSheet' +import type { Resource } from '@/domain/types' export function ResourcesPanel() { - const { currentProcess, resources, addResource, updateResource, deleteResource } = useProcessStore() + const { activities, resources, addResource } = useProcessStore() const { user } = useAuth() - const [showForm, setShowForm] = useState(false) - const [editingId, setEditingId] = useState(null) - const [form, setForm] = useState(EMPTY_FORM) + const navigate = useNavigate() + const [sheetOpen, setSheetOpen] = useState(false) - function startAdd() { - setForm(EMPTY_FORM) - setEditingId(null) - setShowForm(true) - } + const usedResourceIds = new Set( + activities.flatMap((a) => a.assignedResources ?? []).map((ar) => ar.resourceId) + ) + 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) { - setForm({ name: resource.name, type: resource.type, costPerHour: resource.costPerHour.toString() }) - 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() + async function handleSave(resource: Resource) { + await addResource(resource, user!.id) } return ( - -
- {/* Header */} -
-
-

Recursos del proceso

-

{resources.length} definido{resources.length !== 1 ? 's' : ''}

-
- {!showForm && ( - +
+ {/* Header */} +
+

Recursos

+ +
+ + {/* Cuerpo */} + +
+

+ Usados en este proceso ({usedResources.length}) +

+ + {usedResources.length === 0 ? ( +
+

Ningún recurso asignado a actividades aún

+
+ ) : ( +
+ {usedResources.map((resource) => ( +
+ +
+
+

{resource.name}

+

{formatCurrency(resource.costPerHour)}/h

+
+

+ {RESOURCE_TYPE_LABELS[resource.type]} · {usageCount(resource.id)} actividad{usageCount(resource.id) !== 1 ? 'es' : ''} +

+
+
+ ))} +
)}
+
- {/* Formulario */} - {showForm && ( -
-

{editingId ? 'Editar recurso' : 'Nuevo recurso'}

- -
- - setForm((f) => ({ ...f, name: e.target.value }))} - className="h-8 text-xs" - /> -
- -
- - -
- -
- - setForm((f) => ({ ...f, costPerHour: e.target.value }))} - className="h-8 text-xs font-mono" - /> -
- -
- - -
-
- )} - - {/* Lista */} - {resources.length === 0 && !showForm ? ( -
-

Sin recursos definidos

-

Los recursos (roles, sistemas, equipos) se asignan a actividades para calcular su costo por hora

-
- ) : ( -
- {resources.map((resource) => ( -
-
-
-

{resource.name}

- - {RESOURCE_TYPE_LABELS[resource.type]} - -
-

{formatCurrency(resource.costPerHour)}/hora

-
-
- - -
-
- ))} -
- )} + {/* Footer */} +
+

Recursos del catálogo global del equipo

+
- + + +
) } diff --git a/src/persistence/supabase/resource-repo.ts b/src/persistence/supabase/resource-repo.ts index d0cc24e..739d98f 100644 --- a/src/persistence/supabase/resource-repo.ts +++ b/src/persistence/supabase/resource-repo.ts @@ -10,6 +10,19 @@ function fromRow(row: Record): 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): ResourceWithCreator { + const creator = row.creator as { name: string | null } | null + return { + ...fromRow(row), + creatorName: creator?.name ?? 'Desconocido', + } +} + export const supabaseResourceRepo = { async save(resource: Resource, userId: string): Promise { const { error } = await supabase.from('resources').upsert({ @@ -33,7 +46,27 @@ export const supabaseResourceRepo = { return (data ?? []).map(fromRow) }, + // JOIN con users para resolver "creado por" en una sola query (mismo patrón que Etapa 6) + async getAllWithCreator(): Promise { + 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)) + }, + + // No elimina si el recurso tiene asignaciones activas en activity_resource_assignments async delete(resourceId: string): Promise { + 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) if (error) throw error }, diff --git a/src/router.tsx b/src/router.tsx index 7c2af06..28c993c 100644 --- a/src/router.tsx +++ b/src/router.tsx @@ -4,6 +4,7 @@ import { WorkspacePage } from '@/features/workspace/WorkspacePage' import { ImportPage } from '@/features/import/ImportPage' import { LibraryPage } from '@/features/library/LibraryPage' import { SettingsPage } from '@/features/settings/SettingsPage' +import { ResourceCatalogPage } from '@/features/resources/ResourceCatalogPage' import { LoginPage } from '@/features/login/LoginPage' import { useAuth } from '@/auth/AuthContext' import { Loader2 } from 'lucide-react' @@ -94,6 +95,12 @@ const settingsRoute = createRoute({ component: SettingsPage, }) +const resourcesRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/recursos', + component: ResourceCatalogPage, +}) + const routeTree = rootRoute.addChildren([ loginRoute, indexRoute, @@ -101,6 +108,7 @@ const routeTree = rootRoute.addChildren([ workspaceRoute, reportRoute, settingsRoute, + resourcesRoute, ]) export const router = createRouter({ routeTree }) diff --git a/tests/domain/simulation-sprint3.test.ts b/tests/domain/simulation-sprint3.test.ts index 34e14a8..685e056 100644 --- a/tests/domain/simulation-sprint3.test.ts +++ b/tests/domain/simulation-sprint3.test.ts @@ -15,7 +15,7 @@ const SINGLE_TASK_BPMN = ` ` 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 => ({ diff --git a/tests/domain/simulation.test.ts b/tests/domain/simulation.test.ts index fa9de06..b3014a9 100644 --- a/tests/domain/simulation.test.ts +++ b/tests/domain/simulation.test.ts @@ -68,7 +68,6 @@ describe('parseBpmnXml', () => { describe('runSimulation', () => { const makeResource = (id: string, name: string, costPerHour: number): Resource => ({ id, - processId: 'proc1', name, type: 'role', costPerHour, @@ -398,7 +397,7 @@ describe('runSimulation — scenario automated', () => { describe('runSimulation — recursos con modelo utilizationMinutes + units', () => { 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 = ( diff --git a/tests/features/report/derivations.test.ts b/tests/features/report/derivations.test.ts index 46ab4f3..ac5784c 100644 --- a/tests/features/report/derivations.test.ts +++ b/tests/features/report/derivations.test.ts @@ -258,7 +258,7 @@ describe('aggregateResourceCosts', () => { it('con recursos de tipo role: suma correctamente por tipo', () => { const resource: Resource = { - id: 'r1', processId: 'p1', name: 'Analista', type: 'role', costPerHour: 60, + id: 'r1', name: 'Analista', type: 'role', costPerHour: 60, } const perActivity = [{ activityId: 'a1', bpmnElementId: 't1', activityName: 'Task', type: 'task' as const, diff --git a/tests/lib/export/pdf-export-sprint2.test.ts b/tests/lib/export/pdf-export-sprint2.test.ts index 3d280ff..937229b 100644 --- a/tests/lib/export/pdf-export-sprint2.test.ts +++ b/tests/lib/export/pdf-export-sprint2.test.ts @@ -65,7 +65,7 @@ const mockSimNoResources: Simulation = { // Simulación CON recursos en una actividad const mockResource: Resource = { - id: 'r1', processId: 'p1', name: 'Analista', type: 'role', costPerHour: 60, + id: 'r1', name: 'Analista', type: 'role', costPerHour: 60, } const mockActivity: Activity = { diff --git a/tests/lib/export/pdf-resources-section.test.ts b/tests/lib/export/pdf-resources-section.test.ts index f5d7ecc..92a749a 100644 --- a/tests/lib/export/pdf-resources-section.test.ts +++ b/tests/lib/export/pdf-resources-section.test.ts @@ -22,7 +22,7 @@ const mockDoc = { // ─── Fixtures ───────────────────────────────────────────────────────────────── 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 = {