feat: MVP Fase 0 — Process Cost Platform v0.1.0

Plataforma completa de análisis de costos operativos basada en BPMN 2.0.

Funcionalidades incluidas:
- Import de archivos BPMN 2.0 por drag & drop + 3 procesos de ejemplo
- Visualización read-only del diagrama (bpmn-js)
- Configuración de actividades (costo, tiempo, recursos asignados)
- CRUD de recursos (rol, persona, sistema, equipo, insumo)
- Configuración global (moneda, overhead %, nombre, cliente)
- Motor de simulación determinístico agregado con propagación de gateways XOR/AND
- Reporte visual con mapa de calor en dos modos (relativo/absoluto)
- Gráficos: KPIs, top actividades, composición, costo por recurso
- Export PDF (jsPDF + html2canvas) y CSV (papaparse)
- Persistencia en IndexedDB (Dexie)
- 268 tests unitarios/integración + 6 E2E con Playwright
- Deploy: https://process-cost-platform.pages.dev

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-14 01:58:40 -03:00
commit bed161c92a
97 changed files with 19638 additions and 0 deletions

14
src/App.tsx Normal file
View File

@@ -0,0 +1,14 @@
import { RouterProvider } from '@tanstack/react-router'
import { router } from './router'
import { Toaster } from '@/components/ui/toaster'
export function App() {
return (
<>
<RouterProvider router={router} />
<Toaster />
</>
)
}
export default App

View File

@@ -0,0 +1,9 @@
export function AppFooter() {
return (
<footer className="mt-12 py-4 border-t border-slate-100 text-center">
<p className="text-xs text-slate-300">
Process Cost Platform · v0.1.0 · Hecho desde Paraguay 🇵🇾
</p>
</footer>
)
}

View File

@@ -0,0 +1,30 @@
import * as React from 'react'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils'
const badgeVariants = cva(
'inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
{
variants: {
variant: {
default: 'border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80',
secondary: 'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80',
destructive: 'border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80',
outline: 'text-foreground',
},
},
defaultVariants: {
variant: 'default',
},
}
)
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return <div className={cn(badgeVariants({ variant }), className)} {...props} />
}
export { Badge, badgeVariants }

View File

@@ -0,0 +1,48 @@
import * as React from 'react'
import { Slot } from '@radix-ui/react-slot'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils'
const buttonVariants = cva(
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50',
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground shadow hover:bg-primary/90',
destructive: 'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90',
outline: 'border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground',
secondary: 'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground',
link: 'text-primary underline-offset-4 hover:underline',
},
size: {
default: 'h-9 px-4 py-2',
sm: 'h-8 rounded-md px-3 text-xs',
lg: 'h-10 rounded-md px-8',
icon: 'h-9 w-9',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
}
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : 'button'
return (
<Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />
)
}
)
Button.displayName = 'Button'
export { Button, buttonVariants }

View File

@@ -0,0 +1,55 @@
import * as React from 'react'
import { cn } from '@/lib/utils'
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn('rounded-xl border bg-card text-card-foreground shadow', className)}
{...props}
/>
)
)
Card.displayName = 'Card'
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('flex flex-col space-y-1.5 p-6', className)} {...props} />
)
)
CardHeader.displayName = 'CardHeader'
const CardTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn('font-semibold leading-none tracking-tight', className)}
{...props}
/>
)
)
CardTitle.displayName = 'CardTitle'
const CardDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<p ref={ref} className={cn('text-sm text-muted-foreground', className)} {...props} />
))
CardDescription.displayName = 'CardDescription'
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('p-6 pt-0', className)} {...props} />
)
)
CardContent.displayName = 'CardContent'
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('flex items-center p-6 pt-0', className)} {...props} />
)
)
CardFooter.displayName = 'CardFooter'
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }

View File

@@ -0,0 +1,21 @@
import * as React from 'react'
import { cn } from '@/lib/utils'
export type InputProps = React.InputHTMLAttributes<HTMLInputElement>
const Input = React.forwardRef<HTMLInputElement, InputProps>(({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
'flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50',
className
)}
ref={ref}
{...props}
/>
)
})
Input.displayName = 'Input'
export { Input }

View File

@@ -0,0 +1,18 @@
import * as React from 'react'
import * as LabelPrimitive from '@radix-ui/react-label'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils'
const labelVariants = cva(
'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70'
)
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> & VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root ref={ref} className={cn(labelVariants(), className)} {...props} />
))
Label.displayName = LabelPrimitive.Root.displayName
export { Label }

View File

@@ -0,0 +1,43 @@
import * as React from 'react'
import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area'
import { cn } from '@/lib/utils'
const ScrollArea = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
>(({ className, children, ...props }, ref) => (
<ScrollAreaPrimitive.Root
ref={ref}
className={cn('relative overflow-hidden', className)}
{...props}
>
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
))
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName
const ScrollBar = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
>(({ className, orientation = 'vertical', ...props }, ref) => (
<ScrollAreaPrimitive.ScrollAreaScrollbar
ref={ref}
orientation={orientation}
className={cn(
'flex touch-none select-none transition-colors',
orientation === 'vertical' && 'h-full w-2.5 border-l border-l-transparent p-[1px]',
orientation === 'horizontal' && 'h-2.5 flex-col border-t border-t-transparent p-[1px]',
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
</ScrollAreaPrimitive.ScrollAreaScrollbar>
))
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName
export { ScrollArea, ScrollBar }

View File

@@ -0,0 +1,147 @@
import * as React from 'react'
import * as SelectPrimitive from '@radix-ui/react-select'
import { Check, ChevronDown, ChevronUp } from 'lucide-react'
import { cn } from '@/lib/utils'
const Select = SelectPrimitive.Root
const SelectGroup = SelectPrimitive.Group
const SelectValue = SelectPrimitive.Value
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
'flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1',
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
))
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
const SelectScrollUpButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn('flex cursor-default items-center justify-center py-1', className)}
{...props}
>
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
))
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
const SelectScrollDownButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn('flex cursor-default items-center justify-center py-1', className)}
{...props}
>
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
))
SelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayName
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = 'popper', ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
'relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
position === 'popper' &&
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
className
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
'p-1',
position === 'popper' &&
'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]'
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
))
SelectContent.displayName = SelectPrimitive.Content.displayName
const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn('px-2 py-1.5 text-sm font-semibold', className)}
{...props}
/>
))
SelectLabel.displayName = SelectPrimitive.Label.displayName
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
'relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className
)}
{...props}
>
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
))
SelectItem.displayName = SelectPrimitive.Item.displayName
const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
className={cn('-mx-1 my-1 h-px bg-muted', className)}
{...props}
/>
))
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton,
}

View File

@@ -0,0 +1,23 @@
import * as React from 'react'
import * as SeparatorPrimitive from '@radix-ui/react-separator'
import { cn } from '@/lib/utils'
const Separator = React.forwardRef<
React.ElementRef<typeof SeparatorPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
>(({ className, orientation = 'horizontal', decorative = true, ...props }, ref) => (
<SeparatorPrimitive.Root
ref={ref}
decorative={decorative}
orientation={orientation}
className={cn(
'shrink-0 bg-border',
orientation === 'horizontal' ? 'h-[1px] w-full' : 'h-full w-[1px]',
className
)}
{...props}
/>
))
Separator.displayName = SeparatorPrimitive.Root.displayName
export { Separator }

View File

@@ -0,0 +1,22 @@
import * as React from 'react'
import * as SliderPrimitive from '@radix-ui/react-slider'
import { cn } from '@/lib/utils'
const Slider = React.forwardRef<
React.ElementRef<typeof SliderPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root>
>(({ className, ...props }, ref) => (
<SliderPrimitive.Root
ref={ref}
className={cn('relative flex w-full touch-none select-none items-center', className)}
{...props}
>
<SliderPrimitive.Track className="relative h-1.5 w-full grow overflow-hidden rounded-full bg-primary/20">
<SliderPrimitive.Range className="absolute h-full bg-primary" />
</SliderPrimitive.Track>
<SliderPrimitive.Thumb className="block h-4 w-4 rounded-full border border-primary/50 bg-background shadow transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50" />
</SliderPrimitive.Root>
))
Slider.displayName = SliderPrimitive.Root.displayName
export { Slider }

View File

@@ -0,0 +1,56 @@
import * as React from 'react'
import { cn } from '@/lib/utils'
const Table = React.forwardRef<HTMLTableElement, React.HTMLAttributes<HTMLTableElement>>(
({ className, ...props }, ref) => (
<div className="relative w-full overflow-auto">
<table ref={ref} className={cn('w-full caption-bottom text-sm', className)} {...props} />
</div>
)
)
Table.displayName = 'Table'
const TableHeader = React.forwardRef<HTMLTableSectionElement, React.HTMLAttributes<HTMLTableSectionElement>>(
({ className, ...props }, ref) => (
<thead ref={ref} className={cn('[&_tr]:border-b', className)} {...props} />
)
)
TableHeader.displayName = 'TableHeader'
const TableBody = React.forwardRef<HTMLTableSectionElement, React.HTMLAttributes<HTMLTableSectionElement>>(
({ className, ...props }, ref) => (
<tbody ref={ref} className={cn('[&_tr:last-child]:border-0', className)} {...props} />
)
)
TableBody.displayName = 'TableBody'
const TableRow = React.forwardRef<HTMLTableRowElement, React.HTMLAttributes<HTMLTableRowElement>>(
({ className, ...props }, ref) => (
<tr
ref={ref}
className={cn('border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted', className)}
{...props}
/>
)
)
TableRow.displayName = 'TableRow'
const TableHead = React.forwardRef<HTMLTableCellElement, React.ThHTMLAttributes<HTMLTableCellElement>>(
({ className, ...props }, ref) => (
<th
ref={ref}
className={cn('h-10 px-3 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0', className)}
{...props}
/>
)
)
TableHead.displayName = 'TableHead'
const TableCell = React.forwardRef<HTMLTableCellElement, React.TdHTMLAttributes<HTMLTableCellElement>>(
({ className, ...props }, ref) => (
<td ref={ref} className={cn('px-3 py-2.5 align-middle [&:has([role=checkbox])]:pr-0', className)} {...props} />
)
)
TableCell.displayName = 'TableCell'
export { Table, TableHeader, TableBody, TableRow, TableHead, TableCell }

View File

@@ -0,0 +1,52 @@
import * as React from 'react'
import * as TabsPrimitive from '@radix-ui/react-tabs'
import { cn } from '@/lib/utils'
const Tabs = TabsPrimitive.Root
const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn(
'inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground',
className
)}
{...props}
/>
))
TabsList.displayName = TabsPrimitive.List.displayName
const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
'inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow',
className
)}
{...props}
/>
))
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
className={cn(
'mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
className
)}
{...props}
/>
))
TabsContent.displayName = TabsPrimitive.Content.displayName
export { Tabs, TabsList, TabsTrigger, TabsContent }

View File

@@ -0,0 +1,98 @@
import * as React from 'react'
import * as ToastPrimitives from '@radix-ui/react-toast'
import { cva, type VariantProps } from 'class-variance-authority'
import { X } from 'lucide-react'
import { cn } from '@/lib/utils'
const ToastProvider = ToastPrimitives.Provider
const ToastViewport = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Viewport>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Viewport
ref={ref}
className={cn(
'fixed bottom-0 right-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]',
className
)}
{...props}
/>
))
ToastViewport.displayName = ToastPrimitives.Viewport.displayName
const toastVariants = cva(
'group pointer-events-auto relative flex w-full items-center justify-between space-x-2 overflow-hidden rounded-md border p-4 pr-6 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full',
{
variants: {
variant: {
default: 'border bg-background text-foreground',
destructive: 'destructive group border-destructive bg-destructive text-destructive-foreground',
},
},
defaultVariants: { variant: 'default' },
}
)
const Toast = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> & VariantProps<typeof toastVariants>
>(({ className, variant, ...props }, ref) => (
<ToastPrimitives.Root
ref={ref}
className={cn(toastVariants({ variant }), className)}
{...props}
/>
))
Toast.displayName = ToastPrimitives.Root.displayName
const ToastAction = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Action>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Action
ref={ref}
className={cn('inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium transition-colors hover:bg-secondary focus:outline-none focus:ring-1 focus:ring-ring disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive', className)}
{...props}
/>
))
ToastAction.displayName = ToastPrimitives.Action.displayName
const ToastClose = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Close>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Close
ref={ref}
className={cn('absolute right-1 top-1 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-1 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600', className)}
toast-close=""
{...props}
>
<X className="h-4 w-4" />
</ToastPrimitives.Close>
))
ToastClose.displayName = ToastPrimitives.Close.displayName
const ToastTitle = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Title>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Title ref={ref} className={cn('text-sm font-semibold [&+div]:text-xs', className)} {...props} />
))
ToastTitle.displayName = ToastPrimitives.Title.displayName
const ToastDescription = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Description>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Description ref={ref} className={cn('text-sm opacity-90', className)} {...props} />
))
ToastDescription.displayName = ToastPrimitives.Description.displayName
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>
type ToastActionElement = React.ReactElement<typeof ToastAction>
export {
type ToastProps, type ToastActionElement,
ToastProvider, ToastViewport, Toast, ToastTitle, ToastDescription, ToastAction, ToastClose,
}

View File

@@ -0,0 +1,20 @@
import { useToast } from './use-toast'
import { Toast, ToastClose, ToastDescription, ToastProvider, ToastTitle, ToastViewport } from './toast'
export function Toaster() {
const { toasts } = useToast()
return (
<ToastProvider>
{toasts.map(({ id, title, description, ...props }) => (
<Toast key={id} {...props}>
<div className="grid gap-1">
{title && <ToastTitle>{title}</ToastTitle>}
{description && <ToastDescription>{description}</ToastDescription>}
</div>
<ToastClose />
</Toast>
))}
<ToastViewport />
</ToastProvider>
)
}

View File

@@ -0,0 +1,27 @@
import * as React from 'react'
import * as TooltipPrimitive from '@radix-ui/react-tooltip'
import { cn } from '@/lib/utils'
const TooltipProvider = TooltipPrimitive.Provider
const Tooltip = TooltipPrimitive.Root
const TooltipTrigger = TooltipPrimitive.Trigger
const TooltipContent = React.forwardRef<
React.ElementRef<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
'z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
className
)}
{...props}
/>
</TooltipPrimitive.Portal>
))
TooltipContent.displayName = TooltipPrimitive.Content.displayName
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }

View File

@@ -0,0 +1,70 @@
import * as React from 'react'
import type { ToastProps } from './toast'
const TOAST_LIMIT = 3
const TOAST_REMOVE_DELAY = 4000
type ToasterToast = ToastProps & {
id: string
title?: React.ReactNode
description?: React.ReactNode
}
type Action =
| { type: 'ADD_TOAST'; toast: ToasterToast }
| { type: 'DISMISS_TOAST'; toastId?: string }
| { type: 'REMOVE_TOAST'; toastId?: string }
interface State { toasts: ToasterToast[] }
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>()
function reducer(state: State, action: Action): State {
switch (action.type) {
case 'ADD_TOAST':
return { ...state, toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT) }
case 'DISMISS_TOAST': {
const { toastId } = action
if (toastId) {
if (!toastTimeouts.has(toastId)) {
toastTimeouts.set(toastId, setTimeout(() => {
dispatch({ type: 'REMOVE_TOAST', toastId })
toastTimeouts.delete(toastId)
}, TOAST_REMOVE_DELAY))
}
}
return { ...state, toasts: state.toasts.map((t) => t.id === toastId || !toastId ? { ...t, open: false } : t) }
}
case 'REMOVE_TOAST':
return { ...state, toasts: action.toastId ? state.toasts.filter((t) => t.id !== action.toastId) : [] }
}
}
const listeners: ((state: State) => void)[] = []
let memoryState: State = { toasts: [] }
function dispatch(action: Action) {
memoryState = reducer(memoryState, action)
listeners.forEach((l) => l(memoryState))
}
let count = 0
function genId() { return `${++count}` }
function toast(props: Omit<ToasterToast, 'id'>) {
const id = genId()
const dismiss = () => dispatch({ type: 'DISMISS_TOAST', toastId: id })
dispatch({ type: 'ADD_TOAST', toast: { ...props, id, open: true, onOpenChange: (open) => { if (!open) dismiss() } } })
return { id, dismiss }
}
export function useToast() {
const [state, setState] = React.useState<State>(memoryState)
React.useEffect(() => {
listeners.push(setState)
return () => { const idx = listeners.indexOf(setState); if (idx > -1) listeners.splice(idx, 1) }
}, [])
return { ...state, toast, dismiss: (toastId?: string) => dispatch({ type: 'DISMISS_TOAST', toastId }) }
}
export { toast }

153
src/domain/bpmn-parser.ts Normal file
View File

@@ -0,0 +1,153 @@
import type {
ProcessGraph,
ProcessGraphNode,
ProcessGraphFlow,
BpmnNodeType,
BpmnElementId,
} from './types'
// Mapeo de tags XML BPMN → nuestro tipo normalizado
const TAG_TO_TYPE: Record<string, BpmnNodeType> = {
startevent: 'startEvent',
endevent: 'endEvent',
task: 'task',
usertask: 'userTask',
servicetask: 'serviceTask',
scripttask: 'scriptTask',
manualtask: 'manualTask',
businessruletask: 'businessRuleTask',
subprocess: 'subprocess',
exclusivegateway: 'exclusiveGateway',
parallelgateway: 'parallelGateway',
inclusivegateway: 'inclusiveGateway',
eventbasedgateway: 'eventBasedGateway',
intermediatethrowevent: 'intermediateThrowEvent',
intermediatecatchevent: 'intermediateCatchEvent',
}
function normalizeLocalName(tagName: string): string {
// Eliminar namespace prefix (bpmn:Task → task)
const local = tagName.includes(':') ? tagName.split(':')[1] : tagName
return local.toLowerCase()
}
function getAttr(el: Element, name: string): string {
return el.getAttribute(name) ?? ''
}
function parseFlowRefs(el: Element, tagName: string): string[] {
return Array.from(el.getElementsByTagNameNS('*', tagName)).map((ref) => ref.textContent?.trim() ?? '')
}
export class BpmnParseError extends Error {
constructor(message: string) {
super(message)
this.name = 'BpmnParseError'
}
}
export function parseBpmnXml(xml: string): ProcessGraph {
let doc: Document
try {
const parser = new DOMParser()
doc = parser.parseFromString(xml, 'application/xml')
} catch {
throw new BpmnParseError('El archivo no es un XML válido')
}
const parserError = doc.querySelector('parsererror')
if (parserError) {
throw new BpmnParseError('XML malformado: ' + parserError.textContent?.slice(0, 200))
}
const nodes = new Map<BpmnElementId, ProcessGraphNode>()
const flows = new Map<string, ProcessGraphFlow>()
const startEventIds: BpmnElementId[] = []
// Buscar el proceso principal (puede estar anidado bajo definitions)
const processEl = doc.querySelector('[*|localName="process"], process')
if (!processEl) {
throw new BpmnParseError('No se encontró un elemento <process> en el BPMN')
}
// Iterar todos los hijos directos del proceso
for (const child of Array.from(processEl.children)) {
const local = normalizeLocalName(child.tagName)
const type: BpmnNodeType = TAG_TO_TYPE[local] ?? 'unknown'
if (local === 'sequenceflow') {
const flow: ProcessGraphFlow = {
id: getAttr(child, 'id'),
sourceRef: getAttr(child, 'sourceRef'),
targetRef: getAttr(child, 'targetRef'),
name: getAttr(child, 'name') || undefined,
}
if (flow.id) flows.set(flow.id, flow)
continue
}
if (type === 'unknown') continue
const id = getAttr(child, 'id')
if (!id) continue
const node: ProcessGraphNode = {
id,
name: getAttr(child, 'name'),
type,
outgoing: parseFlowRefs(child, 'outgoing'),
incoming: parseFlowRefs(child, 'incoming'),
}
nodes.set(id, node)
if (type === 'startEvent') startEventIds.push(id)
}
if (nodes.size === 0) {
throw new BpmnParseError('El proceso no contiene elementos reconocibles')
}
if (startEventIds.length === 0) {
throw new BpmnParseError('El proceso no tiene evento de inicio (StartEvent)')
}
return { nodes, flows, startEventIds }
}
// Extrae solo los IDs y nombres de los task/subprocess — util para inicializar actividades
export function extractActivityElements(
graph: ProcessGraph
): Array<{ bpmnElementId: BpmnElementId; name: string; type: 'task' | 'subprocess' }> {
const result = []
for (const node of graph.nodes.values()) {
if (
['task', 'userTask', 'serviceTask', 'scriptTask', 'manualTask', 'businessRuleTask'].includes(node.type)
) {
result.push({ bpmnElementId: node.id, name: node.name || node.id, type: 'task' as const })
} else if (node.type === 'subprocess') {
result.push({ bpmnElementId: node.id, name: node.name || node.id, type: 'subprocess' as const })
}
}
return result
}
// Extrae gateways divergentes (los que tienen >1 outgoing)
export function extractGatewayElements(
graph: ProcessGraph
): Array<{ bpmnElementId: BpmnElementId; name: string; gatewayType: string; outgoing: string[] }> {
const result = []
for (const node of graph.nodes.values()) {
if (
['exclusiveGateway', 'parallelGateway', 'inclusiveGateway', 'eventBasedGateway'].includes(node.type) &&
node.outgoing.length > 1
) {
result.push({
bpmnElementId: node.id,
name: node.name || node.id,
gatewayType: node.type,
outgoing: node.outgoing,
})
}
}
return result
}

54
src/domain/schemas.ts Normal file
View File

@@ -0,0 +1,54 @@
import { z } from 'zod'
export const ResourceSchema = z.object({
id: z.string().uuid(),
processId: z.string().uuid(),
name: z.string().min(1, 'El nombre es requerido'),
type: z.enum(['role', 'person', 'system', 'equipment', 'supply']),
costPerHour: z.number().min(0, 'El costo no puede ser negativo'),
})
export const ActivityResourceAssignmentSchema = z.object({
resourceId: z.string().uuid(),
utilizationPercent: z.number().min(0).max(1),
})
export const ActivitySchema = z.object({
id: z.string().uuid(),
processId: z.string().uuid(),
bpmnElementId: z.string().min(1),
name: z.string(),
type: z.enum(['task', 'subprocess']),
directCostFixed: z.number().min(0),
executionTimeMinutes: z.number().min(0),
assignedResources: z.array(ActivityResourceAssignmentSchema),
})
export const GatewayBranchSchema = z.object({
flowId: z.string(),
targetElementId: z.string(),
probability: z.number().min(0).max(1),
})
export const GatewayConfigSchema = z.object({
id: z.string().uuid(),
processId: z.string().uuid(),
bpmnElementId: z.string().min(1),
gatewayType: z.enum(['exclusive', 'parallel', 'inclusive', 'event-based']),
branches: z.array(GatewayBranchSchema),
})
export const ProcessSchema = z.object({
id: z.string().uuid(),
name: z.string().min(1, 'El nombre del proceso es requerido'),
clientName: z.string(),
bpmnXml: z.string().min(1),
currency: z.string().length(3, 'Código ISO de 3 letras'),
overheadPercentage: z.number().min(0).max(1),
createdAt: z.number(),
updatedAt: z.number(),
})
export type ResourceInput = z.infer<typeof ResourceSchema>
export type ActivityInput = z.infer<typeof ActivitySchema>
export type ProcessInput = z.infer<typeof ProcessSchema>

255
src/domain/simulation.ts Normal file
View File

@@ -0,0 +1,255 @@
import { parseBpmnXml } from './bpmn-parser'
import type {
SimulationInput,
SimulationResult,
ActivitySimResult,
ResourceSimResult,
ProcessGraph,
BpmnElementId,
} from './types'
import { isGatewayNode } from './types'
// ─── Detección de back-edges (loops) via DFS con coloreado ──────────────────
function findBackEdges(graph: ProcessGraph): Set<string> {
const backEdges = new Set<string>()
const WHITE = 0, GRAY = 1, BLACK = 2
const color = new Map<BpmnElementId, number>()
function dfs(nodeId: BpmnElementId) {
color.set(nodeId, GRAY)
const node = graph.nodes.get(nodeId)
if (!node) { color.set(nodeId, BLACK); return }
for (const flowId of node.outgoing) {
const flow = graph.flows.get(flowId)
if (!flow) continue
const targetColor = color.get(flow.targetRef) ?? WHITE
if (targetColor === GRAY) {
backEdges.add(flowId)
} else if (targetColor === WHITE) {
dfs(flow.targetRef)
}
}
color.set(nodeId, BLACK)
}
for (const startId of graph.startEventIds) {
if ((color.get(startId) ?? 0) === 0) dfs(startId)
}
return backEdges
}
// ─── Detección de loops con mensajes accionables ──────────────────────────────
function detectLoops(graph: ProcessGraph, backEdges: Set<string>): string[] {
const warnings: string[] = []
const seen = new Set<string>()
for (const flowId of backEdges) {
const flow = graph.flows.get(flowId)
if (!flow || seen.has(flow.targetRef)) continue
seen.add(flow.targetRef)
const name = graph.nodes.get(flow.targetRef)?.name || flow.targetRef
warnings.push(
`Loop detectado en el proceso: el nodo "${name}" forma un ciclo. Los costos se calcularán asumiendo una sola ejecución del ciclo.`
)
}
return warnings
}
// ─── Sort topológico (Kahn's algorithm, ignora back-edges) ───────────────────
function topologicalSort(graph: ProcessGraph, backEdges: Set<string>): BpmnElementId[] {
const inDegree = new Map<BpmnElementId, number>()
for (const nodeId of graph.nodes.keys()) inDegree.set(nodeId, 0)
for (const [flowId, flow] of graph.flows) {
if (backEdges.has(flowId)) continue
inDegree.set(flow.targetRef, (inDegree.get(flow.targetRef) ?? 0) + 1)
}
const queue: BpmnElementId[] = []
for (const [nodeId, deg] of inDegree) {
if (deg === 0) queue.push(nodeId)
}
const order: BpmnElementId[] = []
while (queue.length > 0) {
const nodeId = queue.shift()!
order.push(nodeId)
const node = graph.nodes.get(nodeId)
if (!node) continue
for (const flowId of node.outgoing) {
if (backEdges.has(flowId)) continue
const flow = graph.flows.get(flowId)
if (!flow) continue
const newDeg = (inDegree.get(flow.targetRef) ?? 0) - 1
inDegree.set(flow.targetRef, newDeg)
if (newDeg === 0) queue.push(flow.targetRef)
}
}
return order
}
// ─── Propagación de probabilidades en orden topológico ───────────────────────
//
// Cada nodo se visita exactamente una vez, con su probabilidad ya acumulada.
// Esto evita el overcounting del BFS (donde nodos convergentes se visitaban
// múltiples veces con probabilidades parciales).
//
// Reglas de acumulación en el nodo TARGET:
// AND-join (parallelGateway convergente): max(existing, branchProb)
// — las ramas paralelas son instancias del mismo flujo, no alternativas.
// Todo lo demás (XOR-join, secuencia, eventos): sum clampeado a 1.0
// — representan caminos mutuamente excluyentes.
function computeExecutionProbabilities(
graph: ProcessGraph,
gatewayProbs: Map<BpmnElementId, Map<string, number>>,
backEdges: Set<string>
): Map<BpmnElementId, number> {
const andJoinIds = new Set<BpmnElementId>()
for (const node of graph.nodes.values()) {
if (node.type === 'parallelGateway' && node.incoming.length > 1 && node.outgoing.length <= 1) {
andJoinIds.add(node.id)
}
}
const probs = new Map<BpmnElementId, number>()
for (const startId of graph.startEventIds) probs.set(startId, 1.0)
const topoOrder = topologicalSort(graph, backEdges)
for (const nodeId of topoOrder) {
const node = graph.nodes.get(nodeId)
if (!node) continue
const nodeProb = probs.get(nodeId) ?? 0
for (const flowId of node.outgoing) {
if (backEdges.has(flowId)) continue
const flow = graph.flows.get(flowId)
if (!flow) continue
let branchProb = nodeProb
if (isGatewayNode(node.type)) {
const gwFlowProbs = gatewayProbs.get(nodeId)
if (gwFlowProbs) {
branchProb = nodeProb * (gwFlowProbs.get(flowId) ?? 0)
} else {
branchProb = node.type === 'parallelGateway'
? nodeProb
: nodeProb / Math.max(node.outgoing.length, 1)
}
}
const targetId = flow.targetRef
const existing = probs.get(targetId) ?? 0
const accumulated = andJoinIds.has(targetId)
? Math.max(existing, branchProb)
: Math.min(existing + branchProb, 1.0)
probs.set(targetId, accumulated)
}
}
return probs
}
// ─── Motor de simulación principal ───────────────────────────────────────────
export function runSimulation(input: SimulationInput): SimulationResult {
const { processXml, activities, gateways, resources, globalSettings } = input
const warnings: string[] = []
const graph = parseBpmnXml(processXml)
const backEdges = findBackEdges(graph)
warnings.push(...detectLoops(graph, backEdges))
const gatewayFlowProbs = new Map<BpmnElementId, Map<string, number>>()
for (const [elemId, gwConfig] of gateways.entries()) {
const flowMap = new Map<string, number>()
for (const branch of gwConfig.branches) flowMap.set(branch.flowId, branch.probability)
gatewayFlowProbs.set(elemId, flowMap)
}
const execProbs = computeExecutionProbabilities(graph, gatewayFlowProbs, backEdges)
const perActivity: ActivitySimResult[] = []
const resourceTotals = new Map<string, { cost: number; minutes: number; name: string }>()
for (const activity of activities.values()) {
const execProb = execProbs.get(activity.bpmnElementId) ?? 0
const node = graph.nodes.get(activity.bpmnElementId)
if (!node) {
warnings.push(
`La actividad "${activity.name}" (${activity.bpmnElementId}) no se encontró en el diagrama y se omitirá del cálculo.`
)
continue
}
const resourceBreakdown: ActivitySimResult['resourceCostBreakdown'] = []
let resourceCostDirect = 0
for (const assignment of activity.assignedResources) {
const resource = resources.get(assignment.resourceId)
if (!resource) continue
const hoursUsed = (activity.executionTimeMinutes / 60) * assignment.utilizationPercent
const cost = resource.costPerHour * hoursUsed * execProb
resourceBreakdown.push({ resourceId: resource.id, resourceName: resource.name, cost })
resourceCostDirect += cost
const prev = resourceTotals.get(resource.id) ?? { cost: 0, minutes: 0, name: resource.name }
resourceTotals.set(resource.id, {
cost: prev.cost + cost,
minutes: prev.minutes + activity.executionTimeMinutes * assignment.utilizationPercent * execProb,
name: resource.name,
})
}
const fixedCostExpected = activity.directCostFixed * execProb
const totalExpectedDirectCost = fixedCostExpected + resourceCostDirect
perActivity.push({
activityId: activity.id,
bpmnElementId: activity.bpmnElementId,
activityName: activity.name || activity.bpmnElementId,
expectedDirectCost: totalExpectedDirectCost,
expectedIndirectCost: 0,
expectedTotalCost: 0,
percentOfTotal: 0,
expectedExecutions: execProb,
executionProbability: execProb,
resourceCostBreakdown: resourceBreakdown,
executionTimeMinutes: activity.executionTimeMinutes,
})
}
const totalDirectCost = perActivity.reduce((s, a) => s + a.expectedDirectCost, 0)
const totalIndirectCost = totalDirectCost * globalSettings.overheadPercentage
const totalCost = totalDirectCost + totalIndirectCost
for (const act of perActivity) {
const directRatio = totalDirectCost > 0 ? act.expectedDirectCost / totalDirectCost : 0
act.expectedIndirectCost = totalIndirectCost * directRatio
act.expectedTotalCost = act.expectedDirectCost + act.expectedIndirectCost
act.percentOfTotal = totalCost > 0 ? (act.expectedTotalCost / totalCost) * 100 : 0
}
perActivity.sort((a, b) => b.expectedTotalCost - a.expectedTotalCost)
const perResource: ResourceSimResult[] = Array.from(resourceTotals.entries()).map(([id, data]) => ({
resourceId: id, resourceName: data.name, totalCost: data.cost, totalMinutesUsed: data.minutes,
}))
const totalTimeMinutes = perActivity.reduce(
(s, a) => s + a.executionTimeMinutes * a.executionProbability,
0
)
return { totalCost, totalDirectCost, totalIndirectCost, totalTimeMinutes, perActivity, perResource, warnings }
}

175
src/domain/types.ts Normal file
View File

@@ -0,0 +1,175 @@
// Tipos de identificadores
export type UUID = string
export type BpmnElementId = string
// ─── Entidades del modelo de dominio ─────────────────────────────────────────
export interface Process {
id: UUID
name: string
clientName: string
bpmnXml: string
currency: string
overheadPercentage: number
createdAt: number
updatedAt: number
}
export type ActivityType = 'task' | 'subprocess'
export interface Activity {
id: UUID
processId: UUID
bpmnElementId: BpmnElementId
name: string
type: ActivityType
directCostFixed: number
executionTimeMinutes: number
assignedResources: ActivityResourceAssignment[]
}
export interface ActivityResourceAssignment {
resourceId: UUID
utilizationPercent: number
}
export type GatewayType = 'exclusive' | 'parallel' | 'inclusive' | 'event-based'
export interface GatewayBranch {
flowId: string
targetElementId: BpmnElementId
probability: number
}
export interface GatewayConfig {
id: UUID
processId: UUID
bpmnElementId: BpmnElementId
gatewayType: GatewayType
branches: GatewayBranch[]
}
export type ResourceType = 'role' | 'person' | 'system' | 'equipment' | 'supply'
export interface Resource {
id: UUID
processId: UUID
name: string
type: ResourceType
costPerHour: number
}
export interface Simulation {
id: UUID
processId: UUID
executedAt: number
result: SimulationResult
}
// ─── Motor de simulación: Input/Output ───────────────────────────────────────
export interface SimulationInput {
processXml: string
activities: Map<BpmnElementId, Activity>
gateways: Map<BpmnElementId, GatewayConfig>
resources: Map<UUID, Resource>
globalSettings: {
currency: string
overheadPercentage: number
}
}
export interface ActivitySimResult {
activityId: UUID
bpmnElementId: BpmnElementId
activityName: string
expectedDirectCost: number
expectedIndirectCost: number
expectedTotalCost: number
percentOfTotal: number
expectedExecutions: number
executionProbability: number
resourceCostBreakdown: Array<{ resourceId: UUID; resourceName: string; cost: number }>
executionTimeMinutes: number
}
export interface ResourceSimResult {
resourceId: UUID
resourceName: string
totalCost: number
totalMinutesUsed: number
}
export interface SimulationResult {
totalCost: number
totalDirectCost: number
totalIndirectCost: number
totalTimeMinutes: number
perActivity: ActivitySimResult[]
perResource: ResourceSimResult[]
warnings: string[]
}
// ─── Grafo en memoria (solo para el parser/motor) ────────────────────────────
export type BpmnNodeType =
| 'startEvent'
| 'endEvent'
| 'task'
| 'userTask'
| 'serviceTask'
| 'scriptTask'
| 'manualTask'
| 'businessRuleTask'
| 'subprocess'
| 'exclusiveGateway'
| 'parallelGateway'
| 'inclusiveGateway'
| 'eventBasedGateway'
| 'intermediateThrowEvent'
| 'intermediateCatchEvent'
| 'unknown'
export interface ProcessGraphNode {
id: BpmnElementId
name: string
type: BpmnNodeType
outgoing: string[]
incoming: string[]
}
export interface ProcessGraphFlow {
id: string
sourceRef: BpmnElementId
targetRef: BpmnElementId
name?: string
}
export interface ProcessGraph {
nodes: Map<BpmnElementId, ProcessGraphNode>
flows: Map<string, ProcessGraphFlow>
startEventIds: BpmnElementId[]
}
// ─── Helpers de clasificación ─────────────────────────────────────────────────
export function isTaskNode(type: BpmnNodeType): boolean {
return ['task', 'userTask', 'serviceTask', 'scriptTask', 'manualTask', 'businessRuleTask', 'subprocess'].includes(type)
}
export function isGatewayNode(type: BpmnNodeType): boolean {
return ['exclusiveGateway', 'parallelGateway', 'inclusiveGateway', 'eventBasedGateway'].includes(type)
}
export function bpmnTypeToActivityType(type: BpmnNodeType): ActivityType {
return type === 'subprocess' ? 'subprocess' : 'task'
}
export function bpmnTypeToGatewayType(type: BpmnNodeType): GatewayType {
switch (type) {
case 'parallelGateway': return 'parallel'
case 'inclusiveGateway': return 'inclusive'
case 'eventBasedGateway': return 'event-based'
default: return 'exclusive'
}
}

View File

@@ -0,0 +1,277 @@
import { useNavigate } from '@tanstack/react-router'
import { useCallback, useState } from 'react'
import { UploadCloud, FileText, Loader2, FlaskConical } from 'lucide-react'
import { v4 as uuidv4 } from 'uuid'
import { Card, CardContent } from '@/components/ui/card'
import { parseBpmnXml, extractActivityElements, extractGatewayElements, BpmnParseError } from '@/domain/bpmn-parser'
import { processRepo, activityRepo, gatewayRepo } from '@/persistence/repositories'
import type { Process, Activity, GatewayConfig } from '@/domain/types'
import { bpmnTypeToGatewayType } from '@/domain/types'
import { useToast } from '@/components/ui/use-toast'
import { AppFooter } from '@/components/AppFooter'
const SAMPLE_PROCESSES = [
{
id: 'simple-linear',
fileName: 'simple-linear.bpmn',
name: 'Proceso lineal simple',
tags: ['5 tareas', 'Sin gateways'],
},
{
id: 'medium-with-gateways',
fileName: 'medium-with-gateways.bpmn',
name: 'Aprobación de crédito',
tags: ['XOR', 'Paralelo', '11 tareas'],
},
{
id: 'complex-with-loop',
fileName: 'complex-with-loop.bpmn',
name: 'Control de calidad con revisión',
tags: ['Loop', 'XOR', '9 tareas'],
},
]
export function ImportPage() {
const navigate = useNavigate()
const { toast } = useToast()
const [isDragging, setIsDragging] = useState(false)
const [isProcessing, setIsProcessing] = useState(false)
const [loadingSampleId, setLoadingSampleId] = useState<string | null>(null)
const processFile = useCallback(async (file: File) => {
if (!file.name.endsWith('.bpmn') && !file.name.endsWith('.xml')) {
toast({
title: 'Formato no válido',
description: 'El archivo debe tener extensión .bpmn o .xml',
variant: 'destructive',
})
return
}
setIsProcessing(true)
try {
const xml = await file.text()
const graph = parseBpmnXml(xml)
const processId = uuidv4()
const now = Date.now()
const process: Process = {
id: processId,
name: file.name.replace(/\.(bpmn|xml)$/, ''),
clientName: '',
bpmnXml: xml,
currency: 'USD',
overheadPercentage: 0.2,
createdAt: now,
updatedAt: now,
}
const activityElements = extractActivityElements(graph)
const activities: Activity[] = activityElements.map((el) => ({
id: uuidv4(),
processId,
bpmnElementId: el.bpmnElementId,
name: el.name,
type: el.type,
directCostFixed: 0,
executionTimeMinutes: 0,
assignedResources: [],
}))
const gatewayElements = extractGatewayElements(graph)
const gateways: GatewayConfig[] = gatewayElements.map((el) => {
const gwType = bpmnTypeToGatewayType(el.gatewayType as any)
const n = el.outgoing.length
return {
id: uuidv4(),
processId,
bpmnElementId: el.bpmnElementId,
gatewayType: gwType,
branches: el.outgoing.map((flowId) => ({
flowId,
targetElementId: graph.flows.get(flowId)?.targetRef ?? '',
probability: gwType === 'parallel' ? 1.0 : parseFloat((1 / n).toFixed(4)),
})),
}
})
await Promise.all([
processRepo.save(process),
activityRepo.saveMany(activities),
gatewayRepo.saveMany(gateways),
])
toast({
title: 'Proceso importado',
description: `${activityElements.length} ${activityElements.length === 1 ? 'actividad' : 'actividades'} detectadas. Configurá los costos para simular.`,
})
navigate({ to: '/workspace/$processId', params: { processId } })
} catch (err) {
setIsProcessing(false)
if (err instanceof BpmnParseError) {
toast({
title: 'BPMN 2.0 no válido',
description: 'El archivo no pudo ser interpretado como un diagrama BPMN 2.0 estándar.',
variant: 'destructive',
})
} else {
toast({
title: 'Error al procesar el archivo',
description: 'Ocurrió un error inesperado. Verificá que el archivo no esté dañado.',
variant: 'destructive',
})
console.error(err)
}
}
}, [navigate, toast])
const onDrop = useCallback((e: React.DragEvent) => {
e.preventDefault()
setIsDragging(false)
const file = e.dataTransfer.files[0]
if (file) processFile(file)
}, [processFile])
const onFileInput = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (file) processFile(file)
}, [processFile])
async function handleSampleLoad(sample: typeof SAMPLE_PROCESSES[0]) {
setLoadingSampleId(sample.id)
try {
const response = await fetch(`/sample-processes/${sample.fileName}`)
if (!response.ok) throw new Error('No se pudo cargar el proceso de ejemplo')
const text = await response.text()
const file = new File([text], sample.fileName, { type: 'text/xml' })
await processFile(file)
} catch (err) {
toast({
title: 'Error al cargar el ejemplo',
description: 'No se pudo descargar el proceso. Intentá de nuevo.',
variant: 'destructive',
})
console.error(err)
} finally {
setLoadingSampleId(null)
}
}
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-slate-100 flex flex-col items-center justify-center p-6">
{/* Header */}
<div className="text-center mb-10">
<div className="inline-flex items-center gap-2 text-primary text-sm font-medium mb-4 bg-primary/10 px-3 py-1 rounded-full">
<FlaskConical className="h-3.5 w-3.5" />
Process Cost Platform · MVP Fase 0
</div>
<h1 className="text-4xl font-bold text-slate-900 mb-3">
Cuantificá el costo operativo<br />de tus procesos
</h1>
<p className="text-lg text-slate-500 max-w-xl">
Importá un archivo BPMN 2.0 y obtené un reporte visual de costos
listo para presentar a tu cliente en minutos.
</p>
</div>
{/* Drop zone */}
<Card className="w-full max-w-lg shadow-lg">
<CardContent className="p-0">
<label
htmlFor="bpmn-file-input"
onDragOver={(e) => { e.preventDefault(); setIsDragging(true) }}
onDragLeave={() => setIsDragging(false)}
onDrop={onDrop}
className={`
flex flex-col items-center justify-center gap-4 p-12 rounded-xl cursor-pointer
border-2 border-dashed transition-all duration-200
${isDragging
? 'border-primary bg-primary/5 scale-[1.01]'
: 'border-slate-200 hover:border-primary/50 hover:bg-slate-50'
}
`}
>
{isProcessing ? (
<>
<Loader2 className="h-12 w-12 text-primary animate-spin" />
<p className="text-slate-600 font-medium">Procesando el diagrama</p>
</>
) : (
<>
<div className={`p-4 rounded-full transition-colors ${isDragging ? 'bg-primary/10' : 'bg-slate-100'}`}>
<UploadCloud className={`h-10 w-10 transition-colors ${isDragging ? 'text-primary' : 'text-slate-400'}`} />
</div>
<div className="text-center">
<p className="text-slate-700 font-semibold text-lg">
Arrastrá tu archivo .bpmn aquí
</p>
<p className="text-slate-400 text-sm mt-1">
o hacé click para seleccionarlo desde tu computadora
</p>
</div>
<div className="flex items-center gap-2 text-xs text-slate-400">
<FileText className="h-3.5 w-3.5" />
BPMN 2.0 XML estándar (.bpmn, .xml)
</div>
</>
)}
<input
id="bpmn-file-input"
type="file"
accept=".bpmn,.xml"
className="hidden"
onChange={onFileInput}
disabled={isProcessing}
/>
</label>
</CardContent>
</Card>
{/* Procesos de ejemplo — visibles directamente */}
<div className="mt-8 w-full max-w-lg">
<p className="text-xs text-slate-400 uppercase tracking-wide font-medium text-center mb-3">
O cargá un proceso de ejemplo con un click
</p>
<div className="grid grid-cols-3 gap-3">
{SAMPLE_PROCESSES.map((sample) => {
const isLoading = loadingSampleId === sample.id
return (
<button
key={sample.id}
onClick={() => handleSampleLoad(sample)}
disabled={isProcessing || loadingSampleId !== null}
className="flex flex-col items-start gap-2 p-3 rounded-xl border border-slate-200 bg-white hover:border-primary/40 hover:shadow-sm transition-all text-left disabled:opacity-50 disabled:cursor-not-allowed"
>
<div className="flex items-center justify-between w-full">
<div className="p-1.5 bg-primary/10 rounded-lg">
{isLoading
? <Loader2 className="h-3.5 w-3.5 text-primary animate-spin" />
: <FileText className="h-3.5 w-3.5 text-primary" />
}
</div>
</div>
<p className="text-xs font-semibold text-slate-700 leading-tight">{sample.name}</p>
<div className="flex flex-wrap gap-1">
{sample.tags.map((tag) => (
<span key={tag} className="text-[10px] bg-slate-100 text-slate-500 px-1.5 py-0.5 rounded-full">
{tag}
</span>
))}
</div>
</button>
)
})}
</div>
</div>
<p className="mt-8 text-xs text-slate-300">
100% en tu navegador · Sin backend · Sin registro · Datos guardados localmente
</p>
<AppFooter />
</div>
)
}

View File

@@ -0,0 +1,119 @@
import { useState } from 'react'
import { ArrowLeft, FileText, Loader2 } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
interface SampleProcess {
id: string
fileName: string
name: string
description: string
tags: string[]
}
const SAMPLE_PROCESSES: SampleProcess[] = [
{
id: 'simple-linear',
fileName: 'simple-linear.bpmn',
name: 'Proceso Lineal Simple',
description: '5 tareas en secuencia sin gateways. Ideal para validar el flujo básico de importación y costeo.',
tags: ['Lineal', '5 tareas', 'Sin gateways'],
},
{
id: 'medium-with-gateways',
fileName: 'medium-with-gateways.bpmn',
name: 'Aprobación de Crédito',
description: '12 tareas con un gateway exclusivo (XOR) y un gateway paralelo (AND). Caso de negocio real.',
tags: ['XOR', 'Paralelo', '12 tareas'],
},
{
id: 'complex-with-loop',
fileName: 'complex-with-loop.bpmn',
name: 'Control de Calidad con Revisión',
description: 'Proceso con un loop de correcciones que vuelve a la inspección. Valida la detección de ciclos.',
tags: ['Loop', 'XOR', '9 tareas'],
},
]
interface SampleProcessLoaderProps {
onBack: () => void
onLoad: (file: File) => void
}
export function SampleProcessLoader({ onBack, onLoad }: SampleProcessLoaderProps) {
const [loadingId, setLoadingId] = useState<string | null>(null)
async function handleSelect(sample: SampleProcess) {
setLoadingId(sample.id)
try {
const response = await fetch(`/sample-processes/${sample.fileName}`)
if (!response.ok) throw new Error('No se pudo cargar el archivo de ejemplo')
const text = await response.text()
const file = new File([text], sample.fileName, { type: 'text/xml' })
onLoad(file)
} catch (err) {
console.error('Error cargando proceso de ejemplo:', err)
setLoadingId(null)
}
}
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-slate-100 flex flex-col items-center justify-center p-6">
<div className="w-full max-w-2xl">
<Button variant="ghost" size="sm" onClick={onBack} className="mb-6 text-slate-500">
<ArrowLeft className="h-4 w-4 mr-1.5" />
Volver
</Button>
<div className="mb-8">
<h2 className="text-2xl font-bold text-slate-900 mb-1">Procesos de ejemplo</h2>
<p className="text-slate-500">Seleccioná un proceso para comenzar la demo sin necesitar un archivo propio.</p>
</div>
<div className="flex flex-col gap-4">
{SAMPLE_PROCESSES.map((sample) => (
<Card
key={sample.id}
className="cursor-pointer hover:shadow-md transition-all border-slate-200 hover:border-primary/30"
onClick={() => handleSelect(sample)}
>
<CardHeader className="pb-3">
<div className="flex items-start justify-between">
<div className="flex items-center gap-3">
<div className="p-2 bg-primary/10 rounded-lg">
<FileText className="h-5 w-5 text-primary" />
</div>
<div>
<CardTitle className="text-base">{sample.name}</CardTitle>
<CardDescription className="text-xs mt-0.5">{sample.fileName}</CardDescription>
</div>
</div>
{loadingId === sample.id ? (
<Loader2 className="h-5 w-5 text-primary animate-spin mt-1" />
) : (
<Button size="sm" variant="outline" className="shrink-0">
Cargar
</Button>
)}
</div>
</CardHeader>
<CardContent className="pt-0">
<p className="text-sm text-slate-600 mb-3">{sample.description}</p>
<div className="flex gap-2 flex-wrap">
{sample.tags.map((tag) => (
<span
key={tag}
className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-slate-100 text-slate-600"
>
{tag}
</span>
))}
</div>
</CardContent>
</Card>
))}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,133 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { ArrowUpDown, ArrowUp, ArrowDown } from 'lucide-react'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { activityColor } from '@/lib/colors'
import { formatCurrency, formatPercent, formatMinutes } from '@/lib/format'
import type { HeatmapMode } from '@/lib/colors'
import type { ActivitySimResult } from '@/domain/types'
type SortKey = 'activityName' | 'expectedDirectCost' | 'expectedIndirectCost' | 'expectedTotalCost' | 'percentOfTotal' | 'executionTimeMinutes' | 'executionProbability'
interface ActivitiesTableProps {
perActivity: ActivitySimResult[]
mode: HeatmapMode
currency: string
highlightedId: string | null
onRowClick: (bpmnElementId: string) => void
}
function SortIcon({ column, sortKey, dir }: { column: SortKey; sortKey: SortKey; dir: 'asc' | 'desc' }) {
if (column !== sortKey) return <ArrowUpDown className="h-3 w-3 ml-1 opacity-40" />
return dir === 'asc'
? <ArrowUp className="h-3 w-3 ml-1 text-primary" />
: <ArrowDown className="h-3 w-3 ml-1 text-primary" />
}
export function ActivitiesTable({ perActivity, mode, currency, highlightedId, onRowClick }: ActivitiesTableProps) {
const [sortKey, setSortKey] = useState<SortKey>('expectedTotalCost')
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('desc')
const rowRefs = useRef(new Map<string, HTMLTableRowElement>())
function handleSort(key: SortKey) {
if (key === sortKey) {
setSortDir((d) => d === 'asc' ? 'desc' : 'asc')
} else {
setSortKey(key)
setSortDir('desc')
}
}
const sorted = useMemo(() => {
return [...perActivity].sort((a, b) => {
const av = a[sortKey]
const bv = b[sortKey]
const mult = sortDir === 'asc' ? 1 : -1
if (typeof av === 'string') return (av as string).localeCompare(bv as string) * mult
return ((av as number) - (bv as number)) * mult
})
}, [perActivity, sortKey, sortDir])
// Scroll a la fila resaltada
useEffect(() => {
if (!highlightedId) return
const row = rowRefs.current.get(highlightedId)
if (row) row.scrollIntoView({ behavior: 'smooth', block: 'center' })
}, [highlightedId])
function Th({ label, column }: { label: string; column: SortKey }) {
return (
<TableHead
className="cursor-pointer select-none hover:text-foreground whitespace-nowrap"
onClick={() => handleSort(column)}
>
<span className="inline-flex items-center text-xs">
{label}
<SortIcon column={column} sortKey={sortKey} dir={sortDir} />
</span>
</TableHead>
)
}
return (
<div className="rounded-xl border border-slate-200 overflow-hidden">
<Table>
<TableHeader className="bg-slate-50">
<tr>
<TableHead className="w-8 text-xs" />
<Th label="Actividad" column="activityName" />
<Th label="Costo directo" column="expectedDirectCost" />
<Th label="Overhead" column="expectedIndirectCost" />
<Th label="Costo total" column="expectedTotalCost" />
<Th label="% total" column="percentOfTotal" />
<Th label="Tiempo" column="executionTimeMinutes" />
<Th label="Prob. ejec." column="executionProbability" />
</tr>
</TableHeader>
<TableBody>
{sorted.map((act) => {
const color = activityColor(act, perActivity, mode)
const isHighlighted = act.bpmnElementId === highlightedId
return (
<TableRow
key={act.activityId}
ref={(el) => { if (el) rowRefs.current.set(act.bpmnElementId, el) }}
onClick={() => onRowClick(act.bpmnElementId)}
className={`cursor-pointer ${isHighlighted ? 'bg-primary/5 ring-1 ring-inset ring-primary/20' : ''}`}
data-state={isHighlighted ? 'selected' : undefined}
>
<TableCell className="py-2">
<div
className="w-3 h-3 rounded-sm flex-shrink-0"
style={{ backgroundColor: color }}
/>
</TableCell>
<TableCell className="text-xs font-medium text-slate-800 max-w-48 truncate">
{act.activityName}
</TableCell>
<TableCell className="text-xs font-mono text-slate-600">
{formatCurrency(act.expectedDirectCost, currency)}
</TableCell>
<TableCell className="text-xs font-mono text-slate-500">
{formatCurrency(act.expectedIndirectCost, currency)}
</TableCell>
<TableCell className="text-xs font-mono font-semibold text-slate-800">
{formatCurrency(act.expectedTotalCost, currency)}
</TableCell>
<TableCell className="text-xs font-mono text-slate-600">
{formatPercent(act.percentOfTotal)}
</TableCell>
<TableCell className="text-xs font-mono text-slate-500">
{act.executionTimeMinutes > 0 ? formatMinutes(act.executionTimeMinutes) : '—'}
</TableCell>
<TableCell className="text-xs font-mono text-slate-500">
{formatPercent(act.executionProbability * 100)}
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
</div>
)
}

View File

@@ -0,0 +1,28 @@
import { useMemo } from 'react'
import ReactECharts from 'echarts-for-react'
import { buildCostByResourceOption } from '@/lib/chart-options'
import type { ActivitySimResult, Resource } from '@/domain/types'
interface CostByResourceChartProps {
perActivity: ActivitySimResult[]
resources: Resource[]
currency: string
}
export function CostByResourceChart({ perActivity, resources, currency }: CostByResourceChartProps) {
const option = useMemo(
() => buildCostByResourceOption(perActivity, resources, currency),
[perActivity, resources, currency]
)
if (!option) {
return (
<div className="flex flex-col items-center justify-center h-64 text-center px-4">
<p className="text-xs text-slate-400 mb-1">Sin recursos asignados</p>
<p className="text-xs text-slate-300">Asigná recursos a las actividades en el workspace para ver este gráfico</p>
</div>
)
}
return <ReactECharts option={option} style={{ height: 300 }} notMerge />
}

View File

@@ -0,0 +1,18 @@
import { useMemo } from 'react'
import ReactECharts from 'echarts-for-react'
import { buildCostCompositionOption } from '@/lib/chart-options'
import type { SimulationResult } from '@/domain/types'
interface CostCompositionChartProps {
result: SimulationResult
currency: string
}
export function CostCompositionChart({ result, currency }: CostCompositionChartProps) {
const option = useMemo(
() => buildCostCompositionOption(result, currency),
[result, currency]
)
return <ReactECharts option={option} style={{ height: 300 }} notMerge />
}

View File

@@ -0,0 +1,279 @@
import { useEffect, useRef, useState, useCallback, forwardRef, useImperativeHandle } from 'react'
import BpmnViewer from 'bpmn-js/lib/Viewer'
import { activityColor } from '@/lib/colors'
import { formatCurrency, formatPercent, formatMinutes } from '@/lib/format'
import type { HeatmapMode } from '@/lib/colors'
import type { ActivitySimResult } from '@/domain/types'
interface TooltipData {
visible: boolean
x: number
y: number
activity: ActivitySimResult | null
}
interface HeatmapCanvasProps {
xml: string
perActivity: ActivitySimResult[]
mode: HeatmapMode
currency: string
selectedId: string | null
onActivityClick: (bpmnElementId: string) => void
minHeight?: number
}
export interface HeatmapCanvasHandle {
highlightElement: (bpmnElementId: string | null) => void
captureImage: () => Promise<string | null>
}
function applyHeatmapToViewer(
viewer: any,
perActivity: ActivitySimResult[],
mode: HeatmapMode
) {
const elementRegistry = viewer.get('elementRegistry') as any
if (!elementRegistry) return
for (const act of perActivity) {
const gfx: SVGElement | null = elementRegistry.getGraphics(act.bpmnElementId)
if (!gfx) continue
// activityColor devuelve NEUTRAL_HEATMAP_COLOR cuando no hay varianza significativa,
// evitando una falsa diferenciación en procesos con costos uniformes.
const color = activityColor(act, perActivity, mode)
const shape = gfx.querySelector('.djs-visual rect') as SVGElement | null
if (shape) {
// Usar style.fill (inline CSS) en lugar de setAttribute: el CSS inline tiene
// mayor especificidad que los estilos de bpmn-js, garantizando que html2canvas
// capture el color real (getAttribute devuelve el atributo, pero el estilo
// computado — que usa html2canvas — podría estar sobreescrito por CSS de bpmn-js).
;(shape as any).style.fill = color
;(shape as any).style.fillOpacity = '0.82'
;(shape as any).style.transition = 'fill 0.35s ease, fill-opacity 0.35s ease'
}
}
}
export const HeatmapCanvas = forwardRef<HeatmapCanvasHandle, HeatmapCanvasProps>(
({ xml, perActivity, mode, currency, selectedId, onActivityClick, minHeight = 500 }, ref) => {
const containerRef = useRef<HTMLDivElement>(null)
const viewerRef = useRef<any>(null)
const [isImported, setIsImported] = useState(false)
// Ref que espeja isImported — permite que captureImage() lea el valor live
// desde dentro de una closure asíncrona (las closures capturan state por valor).
const isImportedRef = useRef(false)
const [tooltip, setTooltip] = useState<TooltipData>({ visible: false, x: 0, y: 0, activity: null })
const activityMap = useRef(new Map<string, ActivitySimResult>())
useEffect(() => {
activityMap.current = new Map(perActivity.map((a) => [a.bpmnElementId, a]))
}, [perActivity])
// Exponer métodos para resaltar y capturar imagen (para el PDF)
useImperativeHandle(ref, () => ({
highlightElement: (bpmnElementId: string | null) => {
const viewer = viewerRef.current
if (!viewer) return
const canvas = viewer.get('canvas') as any
const reg = viewer.get('elementRegistry') as any
reg.forEach((el: any) => canvas.removeMarker(el.id, 'bpmn-selected'))
if (bpmnElementId) {
try { canvas.addMarker(bpmnElementId, 'bpmn-selected') } catch { /* ignorar */ }
}
},
captureImage: async () => {
if (!containerRef.current) return null
// Esperar a que bpmn-js termine de importar y renderizar (máx. 5 s).
// isImportedRef.current se actualiza sincrónicamente en el .then() de importXML,
// por lo que es visible aquí aunque esta closure se haya creado antes.
if (!isImportedRef.current) {
const TIMEOUT_MS = 5_000
const POLL_MS = 100
let elapsed = 0
while (!isImportedRef.current && elapsed < TIMEOUT_MS) {
await new Promise<void>((resolve) => setTimeout(resolve, POLL_MS))
elapsed += POLL_MS
}
if (!isImportedRef.current) {
throw new Error(
'El diagrama aún no terminó de cargar. Esperá unos segundos y reintentá.'
)
}
}
// Esperar a que React corra el useEffect de applyHeatmapToViewer Y que la
// transición CSS de los fills termine (fill 0.35s ease → necesitamos > 350ms).
await new Promise<void>((resolve) => setTimeout(resolve, 500))
const { default: html2canvas } = await import('html2canvas')
try {
const canvas = await html2canvas(containerRef.current, {
scale: 1.5,
useCORS: true,
allowTaint: true,
backgroundColor: '#f8fafc',
logging: false,
})
return canvas.toDataURL('image/jpeg', 0.82)
} catch (err) {
console.warn('html2canvas error:', err)
return null
}
},
}))
// Crear viewer
useEffect(() => {
if (!containerRef.current) return
const viewer = new BpmnViewer({ container: containerRef.current })
viewerRef.current = viewer
return () => {
viewer.destroy()
viewerRef.current = null
isImportedRef.current = false
setIsImported(false)
}
}, [])
// Importar XML.
// bpmn-js 18 puede rechazar la promesa de importXML (error interno de canvas)
// pero igualmente renderiza el diagrama parcialmente y dispara 'import.done'
// en el eventBus antes de rechazar. Escuchamos 'import.done' para setear
// isImported de forma confiable sin depender de que la promesa resuelva.
useEffect(() => {
const viewer = viewerRef.current
if (!viewer || !xml) return
isImportedRef.current = false
setIsImported(false)
const eventBus = viewer.get('eventBus') as any
const onImportDone = () => {
eventBus.off('import.done', onImportDone)
const canvas = viewer.get('canvas') as any
try { canvas.zoom('fit-viewport', 'auto') } catch { /* canvas puede no estar listo */ }
isImportedRef.current = true
setIsImported(true)
}
// Usamos .on + .off manual en lugar de .once para compatibilidad con mocks de test.
eventBus.on('import.done', onImportDone)
viewer.importXML(xml).catch((err: unknown) => {
// Puede rechazar aunque el diagrama ya se haya renderizado (error de root layer en bpmn-js 18).
// 'import.done' ya habrá disparado antes de este catch, por lo que isImported ya es true.
console.warn('HeatmapCanvas importXML error (diagrama puede estar parcialmente renderizado):', err)
})
return () => {
eventBus.off('import.done', onImportDone)
}
}, [xml])
// Aplicar heatmap cuando el XML está listo o el modo cambia
useEffect(() => {
const viewer = viewerRef.current
if (!isImported || !viewer || perActivity.length === 0) return
applyHeatmapToViewer(viewer, perActivity, mode)
}, [isImported, perActivity, mode])
// Resaltar elemento seleccionado externamente
useEffect(() => {
const viewer = viewerRef.current
if (!isImported || !viewer) return
const canvas = viewer.get('canvas') as any
const reg = viewer.get('elementRegistry') as any
reg.forEach((el: any) => canvas.removeMarker(el.id, 'bpmn-selected'))
if (selectedId) {
try { canvas.addMarker(selectedId, 'bpmn-selected') } catch { /* ignorar */ }
}
}, [isImported, selectedId])
// Event listeners de bpmn-js para click y hover
useEffect(() => {
const viewer = viewerRef.current
if (!isImported || !viewer) return
const eventBus = viewer.get('eventBus') as any
const TASK_TYPES = new Set([
'bpmn:Task', 'bpmn:UserTask', 'bpmn:ServiceTask', 'bpmn:ScriptTask',
'bpmn:ManualTask', 'bpmn:BusinessRuleTask', 'bpmn:SubProcess',
])
const onHover = (event: any) => {
const el = event.element
if (!TASK_TYPES.has(el?.type)) { setTooltip((t) => ({ ...t, visible: false })); return }
const act = activityMap.current.get(el.id)
if (!act) return
setTooltip((t) => ({ ...t, visible: true, activity: act }))
}
const onOut = () => setTooltip((t) => ({ ...t, visible: false }))
const onClick = (event: any) => {
const el = event.element
if (!el || !TASK_TYPES.has(el.type)) return
onActivityClick(el.id)
}
eventBus.on('element.hover', onHover)
eventBus.on('element.out', onOut)
eventBus.on('element.click', onClick)
return () => {
eventBus.off('element.hover', onHover)
eventBus.off('element.out', onOut)
eventBus.off('element.click', onClick)
}
}, [isImported, onActivityClick])
const onMouseMove = useCallback((e: React.MouseEvent) => {
setTooltip((t) => ({ ...t, x: e.clientX, y: e.clientY }))
}, [])
return (
<div className="relative" style={{ minHeight }}>
{/* Canvas bpmn-js */}
<div
ref={containerRef}
onMouseMove={onMouseMove}
className="w-full bpmn-container"
style={{ minHeight, height: '100%' }}
/>
{/* Tooltip custom */}
{tooltip.visible && tooltip.activity && (
<div
className="fixed z-50 pointer-events-none bg-white border border-slate-200 shadow-lg rounded-lg p-3 w-56"
style={{ left: tooltip.x + 14, top: tooltip.y - 70 }}
>
<p className="text-xs font-semibold text-slate-800 mb-2 leading-tight">
{tooltip.activity.activityName}
</p>
<div className="space-y-1 text-xs">
<div className="flex justify-between">
<span className="text-slate-500">Costo esperado</span>
<span className="font-mono font-medium">{formatCurrency(tooltip.activity.expectedTotalCost, currency)}</span>
</div>
<div className="flex justify-between">
<span className="text-slate-500">% del total</span>
<span className="font-mono">{formatPercent(tooltip.activity.percentOfTotal)}</span>
</div>
<div className="flex justify-between">
<span className="text-slate-500">Prob. ejecución</span>
<span className="font-mono">{formatPercent(tooltip.activity.executionProbability * 100)}</span>
</div>
{tooltip.activity.executionTimeMinutes > 0 && (
<div className="flex justify-between">
<span className="text-slate-500">Tiempo</span>
<span className="font-mono">{formatMinutes(tooltip.activity.executionTimeMinutes)}</span>
</div>
)}
</div>
</div>
)}
</div>
)
}
)
HeatmapCanvas.displayName = 'HeatmapCanvas'

View File

@@ -0,0 +1,35 @@
import { formatCurrency, formatPercent } from '@/lib/format'
import { heatmapLegendBounds } from '@/lib/colors'
import type { HeatmapMode } from '@/lib/colors'
import type { ActivitySimResult } from '@/domain/types'
interface HeatmapLegendProps {
perActivity: ActivitySimResult[]
mode: HeatmapMode
currency: string
}
export function HeatmapLegend({ perActivity, mode, currency }: HeatmapLegendProps) {
const bounds = heatmapLegendBounds(perActivity, mode)
function formatBound(value: number): string {
if (bounds.unit === 'percent') return formatPercent(value, value % 1 === 0 ? 0 : 1)
return formatCurrency(value, currency)
}
return (
<div className="flex items-center gap-3 mt-3">
<span className="text-xs text-slate-500 shrink-0">Bajo costo</span>
<div className="flex-1 h-3 rounded-full" style={{
background: 'linear-gradient(to right, #10b981, #f59e0b, #ef4444)'
}} />
<span className="text-xs text-slate-500 shrink-0">Alto costo</span>
<div className="h-4 border-l border-slate-200 mx-1" />
<div className="flex items-center gap-4 text-xs text-slate-400 font-mono">
<span>{formatBound(bounds.min)}</span>
<span>{formatBound(bounds.mid)}</span>
<span>{formatBound(bounds.max)}</span>
</div>
</div>
)
}

View File

@@ -0,0 +1,49 @@
import { cn } from '@/lib/utils'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import type { HeatmapMode } from '@/lib/colors'
interface HeatmapModeToggleProps {
mode: HeatmapMode
onChange: (mode: HeatmapMode) => void
}
const MODE_LABELS: Record<HeatmapMode, { label: string; tip: string }> = {
relative: {
label: 'Costo relativo (%)',
tip: 'Colorea cada actividad en función de su % del costo total. Útil para ver proporciones.',
},
absolute: {
label: 'Costo absoluto',
tip: 'Colorea según el costo esperado en la moneda del proceso. Útil para comparar magnitudes reales.',
},
}
export function HeatmapModeToggle({ mode, onChange }: HeatmapModeToggleProps) {
return (
<div className="inline-flex rounded-lg border border-slate-200 bg-slate-50 p-0.5 text-xs">
{(['relative', 'absolute'] as HeatmapMode[]).map((m) => {
const { label, tip } = MODE_LABELS[m]
return (
<Tooltip key={m}>
<TooltipTrigger asChild>
<button
onClick={() => onChange(m)}
className={cn(
'px-3 py-1.5 rounded-md font-medium transition-all',
mode === m
? 'bg-white text-slate-800 shadow-sm'
: 'text-slate-500 hover:text-slate-700'
)}
>
{label}
</button>
</TooltipTrigger>
<TooltipContent side="bottom" className="max-w-52 text-xs">
{tip}
</TooltipContent>
</Tooltip>
)
})}
</div>
)
}

View File

@@ -0,0 +1,72 @@
import { DollarSign, TrendingUp, Clock, LayoutList } from 'lucide-react'
import { formatCurrency, formatPercent, formatMinutes } from '@/lib/format'
import type { SimulationResult } from '@/domain/types'
interface KpiBarProps {
result: SimulationResult
currency: string
}
interface KpiCardProps {
label: string
value: string
sub?: string
icon: React.ReactNode
iconColor: string
}
function KpiCard({ label, value, sub, icon, iconColor }: KpiCardProps) {
return (
<div data-testid="kpi-card" className="bg-white rounded-xl border border-slate-200 shadow-sm p-6 flex items-center gap-4">
<div className={`shrink-0 p-3 rounded-lg ${iconColor}`}>
{icon}
</div>
<div className="min-w-0">
<p className="text-xs font-medium text-slate-500 uppercase tracking-wide mb-1">{label}</p>
<p className="text-2xl font-bold text-slate-900 font-mono leading-tight truncate">{value}</p>
{sub && <p className="text-xs text-slate-400 mt-0.5">{sub}</p>}
</div>
</div>
)
}
export function KpiBar({ result, currency }: KpiBarProps) {
const directPct = result.totalCost > 0
? formatPercent(result.totalDirectCost / result.totalCost * 100)
: '—'
const indirectPct = result.totalCost > 0
? formatPercent(result.totalIndirectCost / result.totalCost * 100)
: '—'
return (
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
<KpiCard
label="Costo total esperado"
value={formatCurrency(result.totalCost, currency)}
icon={<DollarSign className="h-5 w-5 text-primary" />}
iconColor="bg-primary/10"
/>
<KpiCard
label="Directo / Indirecto"
value={formatCurrency(result.totalDirectCost, currency)}
sub={`${directPct} directo · ${formatCurrency(result.totalIndirectCost, currency)} (${indirectPct}) overhead`}
icon={<TrendingUp className="h-5 w-5 text-emerald-600" />}
iconColor="bg-emerald-50"
/>
<KpiCard
label="Tiempo total esperado"
value={formatMinutes(result.totalTimeMinutes)}
sub={`${result.totalTimeMinutes.toFixed(0)} minutos ponderados por probabilidad`}
icon={<Clock className="h-5 w-5 text-amber-600" />}
iconColor="bg-amber-50"
/>
<KpiCard
label="Actividades costeadas"
value={String(result.perActivity.length)}
sub={result.perActivity.length === 1 ? 'actividad en el proceso' : 'actividades en el proceso'}
icon={<LayoutList className="h-5 w-5 text-slate-500" />}
iconColor="bg-slate-100"
/>
</div>
)
}

View File

@@ -0,0 +1,36 @@
import type { Process } from '@/domain/types'
import { formatPercent } from '@/lib/format'
interface MethodologyFooterProps {
process: Process
}
export function MethodologyFooter({ process }: MethodologyFooterProps) {
return (
<footer className="mt-12 pt-6 border-t border-slate-200">
<p className="text-xs font-semibold text-slate-500 uppercase tracking-wide mb-3">Nota metodológica</p>
<div className="text-xs text-slate-400 leading-relaxed space-y-2 max-w-4xl">
<p>
<strong className="text-slate-500">Costo total</strong> = Σ(actividades) costo_directo_esperado + costo_indirecto_esperado.
</p>
<p>
<strong className="text-slate-500">Costo directo por actividad</strong> = (costo_fijo_por_ejecución + Σ(recurso × costo_por_hora × tiempo_de_ejecución × porcentaje_utilización / 60)) × probabilidad_de_ejecución.
</p>
<p>
<strong className="text-slate-500">Costo indirecto</strong> = costo_directo_total × overhead_global ({formatPercent(process.overheadPercentage * 100, 0)}).
Representa costos estructurales no imputables directamente a actividades individuales (alquiler, administración, infraestructura).
</p>
<p>
<strong className="text-slate-500">Probabilidad de ejecución</strong>: calculada mediante propagación hacia adelante desde el evento de inicio,
considerando las probabilidades configuradas en cada gateway de decisión.
Las actividades en el camino principal tienen probabilidad 1 (100%).
Las actividades en ramas condicionales tienen la probabilidad acumulada del camino que las incluye.
</p>
<p className="text-slate-300 pt-1">
Modelo determinístico agregado Noche Cero MVP. Los tiempos y costos son estimaciones.
Para análisis de sensibilidad y distribuciones probabilísticas, ver V1.0.
</p>
</div>
</footer>
)
}

View File

@@ -0,0 +1,67 @@
import { ArrowLeft, Download, FileText, Loader2 } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { formatSimulationTimestamp } from '@/lib/format'
import type { Process } from '@/domain/types'
interface ReportHeaderProps {
process: Process
simulatedAt: number
onBack: () => void
onExportPdf: () => void
onExportCsv: () => void
isExportingPdf?: boolean
isExportingCsv?: boolean
}
export function ReportHeader({
process, simulatedAt, onBack,
onExportPdf, onExportCsv,
isExportingPdf = false, isExportingCsv = false,
}: ReportHeaderProps) {
const { date, time } = formatSimulationTimestamp(simulatedAt)
return (
<div className="flex items-start justify-between gap-6 mb-8">
<div className="min-w-0">
<button
onClick={onBack}
className="flex items-center gap-1.5 text-xs text-slate-400 hover:text-slate-600 transition-colors mb-3"
>
<ArrowLeft className="h-3.5 w-3.5" />
Volver a editar
</button>
<h1 className="text-3xl font-bold text-slate-900 leading-tight">{process.name}</h1>
<p className="text-sm text-slate-400 mt-1.5">
{process.clientName && <><span className="text-slate-600">{process.clientName}</span> · </>}
Simulado el {date} a las {time} · Moneda: <span className="font-medium">{process.currency}</span>
</p>
</div>
<div className="flex items-center gap-2 shrink-0 pt-9">
<Button
variant="outline"
size="sm"
onClick={onExportCsv}
disabled={isExportingCsv || isExportingPdf}
className="gap-1.5 min-w-[120px]"
>
{isExportingCsv
? <><Loader2 className="h-4 w-4 animate-spin" />Generando CSV</>
: <><FileText className="h-4 w-4" />Exportar CSV</>
}
</Button>
<Button
size="sm"
onClick={onExportPdf}
disabled={isExportingPdf || isExportingCsv}
className="gap-1.5 min-w-[120px]"
>
{isExportingPdf
? <><Loader2 className="h-4 w-4 animate-spin" />Generando PDF</>
: <><Download className="h-4 w-4" />Exportar PDF</>
}
</Button>
</div>
</div>
)
}

View File

@@ -0,0 +1,278 @@
import { useCallback, useRef, useState } from 'react'
import { useParams, useNavigate } from '@tanstack/react-router'
import { AlertTriangle, Home } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Separator } from '@/components/ui/separator'
import { TooltipProvider } from '@/components/ui/tooltip'
import { useToast } from '@/components/ui/use-toast'
import { AppFooter } from '@/components/AppFooter'
import { useReportData } from './useReportData'
import { ReportHeader } from './ReportHeader'
import { KpiBar } from './KpiBar'
import { HeatmapCanvas, type HeatmapCanvasHandle } from './HeatmapCanvas'
import { HeatmapLegend } from './HeatmapLegend'
import { HeatmapModeToggle } from './HeatmapModeToggle'
import { WarningsBanner } from './WarningsBanner'
import { TopActivitiesChart } from './TopActivitiesChart'
import { CostCompositionChart } from './CostCompositionChart'
import { CostByResourceChart } from './CostByResourceChart'
import { ActivitiesTable } from './ActivitiesTable'
import { MethodologyFooter } from './MethodologyFooter'
import type { HeatmapMode } from '@/lib/colors'
export function ReportPage() {
const { processId } = useParams({ from: '/report/$processId' })
const navigate = useNavigate()
const { process, simulation, resources, isLoading, error } = useReportData(processId)
const { toast } = useToast()
const [heatmapMode, setHeatmapMode] = useState<HeatmapMode>('relative')
const [selectedId, setSelectedId] = useState<string | null>(null)
const [isExportingPdf, setIsExportingPdf] = useState(false)
const [isExportingCsv, setIsExportingCsv] = useState(false)
const heatmapRef = useRef<HeatmapCanvasHandle>(null)
const heatmapSectionRef = useRef<HTMLDivElement>(null)
const tableSectionRef = useRef<HTMLDivElement>(null)
// Click en heatmap → resaltar fila en tabla + scroll a tabla
const handleHeatmapClick = useCallback((bpmnElementId: string) => {
setSelectedId(bpmnElementId)
setTimeout(() => {
tableSectionRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' })
}, 50)
}, [])
// Click en fila de tabla → resaltar en canvas + scroll a heatmap
const handleTableRowClick = useCallback((bpmnElementId: string) => {
setSelectedId(bpmnElementId)
heatmapRef.current?.highlightElement(bpmnElementId)
setTimeout(() => {
heatmapSectionRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' })
}, 50)
}, [])
const handleExportPdf = useCallback(async () => {
if (!process || !simulation || isExportingPdf) return
setIsExportingPdf(true)
try {
const heatmapImageData = await heatmapRef.current?.captureImage() ?? null
const { exportToPdf } = await import('@/lib/export/pdf-export')
await exportToPdf({ process, simulation, resources, heatmapImageData })
toast({ title: 'PDF generado', description: 'El reporte se descargó correctamente.' })
} catch (err) {
console.error('PDF export error:', err)
toast({ title: 'Error al generar el PDF', description: String(err), variant: 'destructive' })
} finally {
setIsExportingPdf(false)
}
}, [process, simulation, resources, isExportingPdf, toast])
const handleExportCsv = useCallback(async () => {
if (!process || !simulation || isExportingCsv) return
setIsExportingCsv(true)
try {
const { exportToCsv } = await import('@/lib/export/csv-export')
exportToCsv({ process, simulation, resources })
toast({ title: 'CSV generado', description: 'Los datos se descargaron correctamente.' })
} catch (err) {
console.error('CSV export error:', err)
toast({ title: 'Error al exportar CSV', description: String(err), variant: 'destructive' })
} finally {
setIsExportingCsv(false)
}
}, [process, simulation, resources, isExportingCsv, toast])
// ── Loading — skeleton en lugar de pantalla en blanco ────────────────────
if (isLoading) {
return (
<div className="min-h-screen bg-slate-50">
<div className="max-w-screen-xl mx-auto px-6 py-10 animate-pulse">
{/* Header skeleton */}
<div className="flex items-start justify-between mb-8">
<div className="space-y-2">
<div className="h-3 w-20 bg-slate-200 rounded" />
<div className="h-8 w-80 bg-slate-200 rounded" />
<div className="h-3 w-48 bg-slate-200 rounded" />
</div>
<div className="flex gap-2 pt-9">
<div className="h-9 w-32 bg-slate-200 rounded-md" />
<div className="h-9 w-32 bg-slate-200 rounded-md" />
</div>
</div>
{/* KPI skeleton */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
{[0, 1, 2, 3].map((i) => (
<div key={i} className="bg-white rounded-xl border border-slate-200 p-6 flex gap-4">
<div className="h-11 w-11 bg-slate-100 rounded-lg shrink-0" />
<div className="flex-1 space-y-2 pt-1">
<div className="h-2.5 bg-slate-100 rounded w-3/4" />
<div className="h-6 bg-slate-200 rounded w-full" />
<div className="h-2 bg-slate-100 rounded w-1/2" />
</div>
</div>
))}
</div>
{/* Canvas skeleton */}
<div className="bg-white rounded-xl border border-slate-200 p-6 mb-6">
<div className="h-5 w-48 bg-slate-200 rounded mb-4" />
<div className="h-72 bg-slate-100 rounded-lg" />
</div>
{/* Table skeleton */}
<div className="bg-white rounded-xl border border-slate-200 p-6">
<div className="h-4 w-40 bg-slate-200 rounded mb-4" />
{[0, 1, 2, 3].map((i) => (
<div key={i} className="h-10 bg-slate-50 border-b border-slate-100 flex items-center gap-4 px-2">
<div className="h-3 bg-slate-200 rounded flex-1" />
<div className="h-3 bg-slate-200 rounded w-20" />
<div className="h-3 bg-slate-200 rounded w-16" />
</div>
))}
</div>
</div>
</div>
)
}
// ── Error ─────────────────────────────────────────────────────────────────
if (error || !process || !simulation) {
return (
<div className="min-h-screen flex flex-col items-center justify-center gap-4 bg-slate-50 px-6">
<AlertTriangle className="h-10 w-10 text-amber-400" />
<div className="text-center">
<p className="text-sm font-medium text-slate-700 mb-1">No se pudo cargar el reporte</p>
<p className="text-xs text-slate-400 max-w-sm">
{error ?? 'Simulá el proceso desde el workspace para generar el reporte.'}
</p>
</div>
<div className="flex gap-3">
<Button variant="outline" size="sm" onClick={() => navigate({ to: '/' })}>
<Home className="h-4 w-4 mr-1.5" />
Inicio
</Button>
<Button size="sm" onClick={() => navigate({ to: '/workspace/$processId', params: { processId } })}>
Volver a configurar y simular
</Button>
</div>
</div>
)
}
const result = simulation.result
const perActivity = result.perActivity
return (
<TooltipProvider>
<div className="min-h-screen bg-slate-50">
<div className="max-w-screen-xl mx-auto px-6 py-10">
{/* ── 1. Header ─────────────────────────────────────────────── */}
<ReportHeader
process={process}
simulatedAt={simulation.executedAt}
onBack={() => navigate({ to: '/workspace/$processId', params: { processId } })}
onExportPdf={handleExportPdf}
onExportCsv={handleExportCsv}
isExportingPdf={isExportingPdf}
isExportingCsv={isExportingCsv}
/>
{/* ── 2. KPI Bar ────────────────────────────────────────────── */}
<KpiBar result={result} currency={process.currency} />
{/* ── 3. Mapa de Calor ──────────────────────────────────────── */}
<div ref={heatmapSectionRef} className="bg-white rounded-xl border border-slate-200 shadow-sm p-6 mb-6">
<div className="flex items-center justify-between mb-4">
<div>
<h2 className="text-base font-semibold text-slate-800">Mapa de calor de costos</h2>
<p className="text-xs text-slate-400 mt-0.5">
Hacé click en una actividad para ver su detalle en la tabla
</p>
</div>
<HeatmapModeToggle mode={heatmapMode} onChange={setHeatmapMode} />
</div>
<HeatmapCanvas
ref={heatmapRef}
xml={process.bpmnXml}
perActivity={perActivity}
mode={heatmapMode}
currency={process.currency}
selectedId={selectedId}
onActivityClick={handleHeatmapClick}
minHeight={520}
/>
<HeatmapLegend
perActivity={perActivity}
mode={heatmapMode}
currency={process.currency}
/>
</div>
{/* ── 4. Warnings ───────────────────────────────────────────── */}
<WarningsBanner warnings={result.warnings} />
{/* ── 5. Análisis — 3 gráficos ──────────────────────────────── */}
<div className="mb-6">
<h2 className="text-base font-semibold text-slate-800 mb-4">Análisis de composición</h2>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
<div className="bg-white rounded-xl border border-slate-200 shadow-sm p-5">
<h3 className="text-xs font-semibold text-slate-600 uppercase tracking-wide mb-4">
Top actividades por costo
</h3>
<TopActivitiesChart
perActivity={perActivity}
mode={heatmapMode}
currency={process.currency}
/>
</div>
<div className="bg-white rounded-xl border border-slate-200 shadow-sm p-5">
<h3 className="text-xs font-semibold text-slate-600 uppercase tracking-wide mb-4">
Composición directo / overhead
</h3>
<CostCompositionChart result={result} currency={process.currency} />
</div>
<div className="bg-white rounded-xl border border-slate-200 shadow-sm p-5">
<h3 className="text-xs font-semibold text-slate-600 uppercase tracking-wide mb-4">
Costo por tipo de recurso
</h3>
<CostByResourceChart
perActivity={perActivity}
resources={resources}
currency={process.currency}
/>
</div>
</div>
</div>
{/* ── 6. Tabla detalle ──────────────────────────────────────── */}
<div ref={tableSectionRef} className="mb-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-base font-semibold text-slate-800">
Detalle por actividad
<span className="ml-2 text-sm font-normal text-slate-400">({perActivity.length} actividades)</span>
</h2>
</div>
<ActivitiesTable
perActivity={perActivity}
mode={heatmapMode}
currency={process.currency}
highlightedId={selectedId}
onRowClick={handleTableRowClick}
/>
</div>
<Separator className="my-4" />
{/* ── 7. Footer metodológico ────────────────────────────────── */}
<MethodologyFooter process={process} />
<AppFooter />
</div>
</div>
</TooltipProvider>
)
}

View File

@@ -0,0 +1,24 @@
import { useMemo } from 'react'
import ReactECharts from 'echarts-for-react'
import { buildTopActivitiesOption } from '@/lib/chart-options'
import type { HeatmapMode } from '@/lib/colors'
import type { ActivitySimResult } from '@/domain/types'
interface TopActivitiesChartProps {
perActivity: ActivitySimResult[]
mode: HeatmapMode
currency: string
}
export function TopActivitiesChart({ perActivity, mode, currency }: TopActivitiesChartProps) {
const option = useMemo(
() => buildTopActivitiesOption(perActivity, mode, currency),
[perActivity, mode, currency]
)
if (perActivity.length === 0) {
return <div className="flex items-center justify-center h-64 text-xs text-slate-400">Sin datos de actividades</div>
}
return <ReactECharts option={option} style={{ height: 320 }} notMerge />
}

View File

@@ -0,0 +1,36 @@
import { AlertTriangle } from 'lucide-react'
interface WarningsBannerProps {
warnings: string[]
}
export function WarningsBanner({ warnings }: WarningsBannerProps) {
if (warnings.length === 0) return null
const hasLoop = warnings.some((w) => w.toLowerCase().includes('loop') || w.toLowerCase().includes('ciclo'))
return (
<div className="bg-amber-50 border border-amber-200 rounded-xl p-4 mb-6">
<div className="flex items-start gap-3">
<AlertTriangle className="h-5 w-5 text-amber-500 shrink-0 mt-0.5" />
<div className="flex-1">
<p className="text-sm font-semibold text-amber-800 mb-2">
{warnings.length === 1 ? 'Advertencia del motor de simulación' : `${warnings.length} advertencias del motor de simulación`}
</p>
<ul className="space-y-1">
{warnings.map((w, i) => (
<li key={i} className="text-sm text-amber-700 leading-relaxed"> {w}</li>
))}
</ul>
{hasLoop && (
<p className="text-xs text-amber-600 mt-2 border-t border-amber-200 pt-2">
<strong>Impacto en los resultados:</strong> Los procesos con ciclos se calculan asumiendo una sola ejecución del loop.
Los costos reales pueden ser mayores si el loop se repite múltiples veces en cada instancia del proceso.
Para modelado probabilístico de loops, se implementará distribuciones en V1.0.
</p>
)}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,54 @@
import { useEffect, useState } from 'react'
import { processRepo, activityRepo, resourceRepo, simulationRepo } from '@/persistence/repositories'
import type { Process, Activity, Resource, Simulation } from '@/domain/types'
interface ReportData {
process: Process | null
simulation: Simulation | null
activities: Activity[]
resources: Resource[]
isLoading: boolean
error: string | null
}
export function useReportData(processId: string): ReportData {
const [process, setProcess] = useState<Process | null>(null)
const [simulation, setSimulation] = useState<Simulation | null>(null)
const [activities, setActivities] = useState<Activity[]>([])
const [resources, setResources] = useState<Resource[]>([])
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
let cancelled = false
async function load() {
setIsLoading(true)
setError(null)
try {
const [proc, sim, acts, res] = await Promise.all([
processRepo.getById(processId),
simulationRepo.getLatestByProcess(processId),
activityRepo.getByProcess(processId),
resourceRepo.getByProcess(processId),
])
if (cancelled) return
if (!proc) throw new Error('Proceso no encontrado en la base de datos local')
if (!sim) throw new Error('Sin simulación disponible. Volvé al workspace y ejecutá "Simular" primero.')
setProcess(proc)
setSimulation(sim)
setActivities(acts)
setResources(res)
} catch (err) {
if (!cancelled) setError(err instanceof Error ? err.message : 'Error al cargar el reporte')
} finally {
if (!cancelled) setIsLoading(false)
}
}
load()
return () => { cancelled = true }
}, [processId])
return { process, simulation, activities, resources, isLoading, error }
}

View File

@@ -0,0 +1,56 @@
import { useCallback } from 'react'
import { v4 as uuidv4 } from 'uuid'
import { useProcessStore } from '@/store/process-store'
import { useSimulationStore } from '@/store/simulation-store'
import { runSimulation } from '@/domain/simulation'
import { simulationRepo } from '@/persistence/repositories'
import type { SimulationInput } from '@/domain/types'
export function useSimulate() {
const { currentProcess, activities, gateways, resources } = useProcessStore()
const { setResult, setRunning, setError } = useSimulationStore()
const simulate = useCallback(async () => {
if (!currentProcess) {
setError('No hay proceso cargado')
return null
}
setRunning(true)
setError(null)
try {
const input: SimulationInput = {
processXml: currentProcess.bpmnXml,
activities: new Map(activities.map((a) => [a.bpmnElementId, a])),
gateways: new Map(gateways.map((g) => [g.bpmnElementId, g])),
resources: new Map(resources.map((r) => [r.id, r])),
globalSettings: {
currency: currentProcess.currency,
overheadPercentage: currentProcess.overheadPercentage,
},
}
const result = runSimulation(input)
const simulation = {
id: uuidv4(),
processId: currentProcess.id,
executedAt: Date.now(),
result,
}
await simulationRepo.save(simulation)
setResult(result)
setRunning(false)
return result
} catch (err) {
const message = err instanceof Error ? err.message : 'Error en la simulación'
setError(message)
setRunning(false)
return null
}
}, [currentProcess, activities, gateways, resources, setResult, setRunning, setError])
return { simulate }
}

View File

@@ -0,0 +1,241 @@
import { useEffect, useState } from 'react'
import { PlusCircle, Trash2, Info } from 'lucide-react'
import { Label } from '@/components/ui/label'
import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button'
import { Slider } from '@/components/ui/slider'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { ScrollArea } from '@/components/ui/scroll-area'
import { useProcessStore } from '@/store/process-store'
import { formatCurrency } from '@/lib/format'
import type { Activity } from '@/domain/types'
interface ActivityPanelProps {
selectedElementId: string | null
}
export function ActivityPanel({ selectedElementId }: ActivityPanelProps) {
const { activities, resources, updateActivity } = useProcessStore()
const [localActivity, setLocalActivity] = useState<Activity | null>(null)
const [isDirty, setIsDirty] = useState(false)
const activity = activities.find((a) => a.bpmnElementId === selectedElementId) ?? null
// Sincronizar con el store cuando cambia la selección
useEffect(() => {
setLocalActivity(activity ? { ...activity, assignedResources: [...activity.assignedResources] } : null)
setIsDirty(false)
}, [selectedElementId, activities])
function handleChange<K extends keyof Activity>(key: K, value: Activity[K]) {
if (!localActivity) return
setLocalActivity((prev) => prev ? { ...prev, [key]: value } : null)
setIsDirty(true)
}
async function handleSave() {
if (!localActivity) return
await updateActivity(localActivity)
setIsDirty(false)
}
function addResource(resourceId: string) {
if (!localActivity) return
const already = localActivity.assignedResources.some((r) => r.resourceId === resourceId)
if (already) return
handleChange('assignedResources', [
...localActivity.assignedResources,
{ resourceId, utilizationPercent: 1.0 },
])
}
function removeResource(resourceId: string) {
if (!localActivity) return
handleChange(
'assignedResources',
localActivity.assignedResources.filter((r) => r.resourceId !== resourceId)
)
}
function setUtilization(resourceId: string, value: number) {
if (!localActivity) return
handleChange(
'assignedResources',
localActivity.assignedResources.map((r) =>
r.resourceId === resourceId ? { ...r, utilizationPercent: value } : r
)
)
}
if (!selectedElementId) {
return (
<div className="flex flex-col items-center justify-center h-full text-center px-6 py-12">
<div className="w-12 h-12 bg-slate-100 rounded-full flex items-center justify-center mb-3">
<svg className="w-6 h-6 text-slate-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M15 15l-2 5L9 9l11 4-5 2zm0 0l5 5" />
</svg>
</div>
<p className="text-sm font-medium text-slate-600 mb-1">Seleccioná una actividad</p>
<p className="text-xs text-slate-400">Hacé click sobre una tarea en el diagrama para editar sus atributos de costo</p>
</div>
)
}
if (!localActivity) {
return (
<div className="flex items-center justify-center h-full">
<p className="text-xs text-slate-400">Esta actividad no tiene configuración todavía</p>
</div>
)
}
const availableResources = resources.filter(
(r) => !localActivity.assignedResources.some((ar) => ar.resourceId === r.id)
)
return (
<ScrollArea className="h-full">
<div className="p-4 space-y-5">
{/* Nombre (read-only, viene del BPMN) */}
<div>
<Label className="text-xs text-slate-500 mb-1 block">Actividad</Label>
<p className="font-semibold text-slate-800 text-sm leading-tight">{localActivity.name || localActivity.bpmnElementId}</p>
<p className="text-xs text-slate-400 mt-0.5 font-mono">{localActivity.bpmnElementId}</p>
</div>
{/* Costo directo fijo */}
<div className="space-y-1.5">
<div className="flex items-center gap-1.5">
<Label htmlFor="direct-cost" className="text-xs font-medium">Costo directo fijo</Label>
<Tooltip>
<TooltipTrigger asChild>
<Info className="h-3 w-3 text-slate-400 cursor-help" />
</TooltipTrigger>
<TooltipContent side="right" className="max-w-48 text-xs">
Costo fijo por cada ejecución de esta actividad (materiales, licencias, etc.) independiente del tiempo
</TooltipContent>
</Tooltip>
</div>
<Input
id="direct-cost"
type="number"
min={0}
step={10}
value={localActivity.directCostFixed}
onChange={(e) => handleChange('directCostFixed', Math.max(0, parseFloat(e.target.value) || 0))}
className="font-mono h-8 text-sm"
placeholder="0"
/>
</div>
{/* Tiempo de ejecución */}
<div className="space-y-1.5">
<div className="flex items-center gap-1.5">
<Label htmlFor="exec-time" className="text-xs font-medium">Tiempo de ejecución (min)</Label>
<Tooltip>
<TooltipTrigger asChild>
<Info className="h-3 w-3 text-slate-400 cursor-help" />
</TooltipTrigger>
<TooltipContent side="right" className="max-w-48 text-xs">
Tiempo promedio estimado para completar esta actividad
</TooltipContent>
</Tooltip>
</div>
<Input
id="exec-time"
type="number"
min={0}
step={5}
value={localActivity.executionTimeMinutes}
onChange={(e) => handleChange('executionTimeMinutes', Math.max(0, parseFloat(e.target.value) || 0))}
className="font-mono h-8 text-sm"
placeholder="0"
/>
</div>
{/* Recursos asignados */}
<div className="space-y-2">
<Label className="text-xs font-medium block">Recursos asignados</Label>
{localActivity.assignedResources.length === 0 ? (
<p className="text-xs text-slate-400 py-2">Sin recursos asignados</p>
) : (
<div className="space-y-3">
{localActivity.assignedResources.map((assignment) => {
const resource = resources.find((r) => r.id === assignment.resourceId)
if (!resource) return null
const hourlyShare = (resource.costPerHour * assignment.utilizationPercent).toFixed(2)
return (
<div key={assignment.resourceId} className="bg-slate-50 rounded-lg p-3 space-y-2">
<div className="flex items-center justify-between">
<div>
<p className="text-xs font-medium text-slate-700">{resource.name}</p>
<p className="text-xs text-slate-400">{formatCurrency(resource.costPerHour)}/h · {formatCurrency(parseFloat(hourlyShare))}/h efectivo</p>
</div>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-slate-400 hover:text-destructive"
onClick={() => removeResource(assignment.resourceId)}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
<div className="space-y-1">
<div className="flex justify-between">
<span className="text-xs text-slate-500">Utilización</span>
<span className="text-xs font-mono font-medium">{Math.round(assignment.utilizationPercent * 100)}%</span>
</div>
<Slider
value={[assignment.utilizationPercent * 100]}
min={0}
max={100}
step={5}
onValueChange={([v]) => setUtilization(assignment.resourceId, v / 100)}
/>
</div>
</div>
)
})}
</div>
)}
{/* Agregar recurso */}
{availableResources.length > 0 && (
<Select onValueChange={addResource}>
<SelectTrigger className="h-8 text-xs">
<div className="flex items-center gap-1.5 text-slate-500">
<PlusCircle className="h-3.5 w-3.5" />
<SelectValue placeholder="Agregar recurso…" />
</div>
</SelectTrigger>
<SelectContent>
{availableResources.map((r) => (
<SelectItem key={r.id} value={r.id} className="text-xs">
{r.name} {formatCurrency(r.costPerHour)}/h
</SelectItem>
))}
</SelectContent>
</Select>
)}
{resources.length === 0 && (
<p className="text-xs text-slate-400">Creá recursos en la tab "Recursos" para asignarlos</p>
)}
</div>
{/* Guardar */}
<Button
size="sm"
onClick={handleSave}
disabled={!isDirty}
className="w-full"
>
{isDirty ? 'Guardar cambios' : 'Sin cambios'}
</Button>
</div>
</ScrollArea>
)
}

View File

@@ -0,0 +1,82 @@
import { useEffect, useRef } from 'react'
import BpmnViewer from 'bpmn-js/lib/Viewer'
export type BpmnElementCategory = 'activity' | 'gateway' | 'other'
const ACTIVITY_TYPES = new Set([
'bpmn:Task', 'bpmn:UserTask', 'bpmn:ServiceTask', 'bpmn:ScriptTask',
'bpmn:ManualTask', 'bpmn:BusinessRuleTask', 'bpmn:SubProcess',
])
const GATEWAY_TYPES = new Set([
'bpmn:ExclusiveGateway', 'bpmn:ParallelGateway',
'bpmn:InclusiveGateway', 'bpmn:EventBasedGateway',
])
function classifyElement(type: string): BpmnElementCategory {
if (ACTIVITY_TYPES.has(type)) return 'activity'
if (GATEWAY_TYPES.has(type)) return 'gateway'
return 'other'
}
interface BpmnCanvasProps {
xml: string
onElementClick?: (elementId: string, elementName: string, category: BpmnElementCategory) => void
selectedElementId?: string | null
className?: string
}
export function BpmnCanvas({ xml, onElementClick, selectedElementId, className }: BpmnCanvasProps) {
const containerRef = useRef<HTMLDivElement>(null)
const viewerRef = useRef<InstanceType<typeof BpmnViewer> | null>(null)
useEffect(() => {
if (!containerRef.current) return
const viewer = new BpmnViewer({ container: containerRef.current })
viewerRef.current = viewer
return () => {
viewer.destroy()
viewerRef.current = null
}
}, [])
useEffect(() => {
const viewer = viewerRef.current
if (!viewer || !xml) return
viewer.importXML(xml).then(({ warnings }) => {
if (warnings.length) console.warn('bpmn-js warnings:', warnings)
const canvas = viewer.get('canvas') as any
canvas.zoom('fit-viewport', 'auto')
}).catch((err: Error) => console.error('Error cargando BPMN:', err))
}, [xml])
useEffect(() => {
const viewer = viewerRef.current
if (!viewer || !onElementClick) return
const eventBus = viewer.get('eventBus') as any
const handler = (event: any) => {
const el = event.element
if (!el) return
const category = classifyElement(el.type)
if (category === 'other') return
onElementClick(el.id, el.businessObject?.name ?? el.id, category)
}
eventBus.on('element.click', handler)
return () => eventBus.off('element.click', handler)
}, [onElementClick])
useEffect(() => {
const viewer = viewerRef.current
if (!viewer) return
const canvas = viewer.get('canvas') as any
const reg = viewer.get('elementRegistry') as any
reg.forEach((el: any) => canvas.removeMarker(el.id, 'bpmn-selected'))
if (selectedElementId) {
try { canvas.addMarker(selectedElementId, 'bpmn-selected') } catch { /* ignorar */ }
}
}, [selectedElementId])
return <div ref={containerRef} className={className} style={{ width: '100%', height: '100%' }} />
}

View File

@@ -0,0 +1,277 @@
import { useEffect, useMemo, useState } from 'react'
import { Info, AlertTriangle, RotateCcw, Check, GitMerge } from 'lucide-react'
import { Label } from '@/components/ui/label'
import { Button } from '@/components/ui/button'
import { Slider } from '@/components/ui/slider'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { ScrollArea } from '@/components/ui/scroll-area'
import { useProcessStore } from '@/store/process-store'
import { parseBpmnXml } from '@/domain/bpmn-parser'
import { formatPercent } from '@/lib/format'
import type { GatewayConfig } from '@/domain/types'
interface GatewayPanelProps {
selectedElementId: string | null
}
// Tolerancia para validar que la suma de probs XOR = 1.0
const XOR_SUM_TOLERANCE = 0.005
function sumBranches(gw: GatewayConfig): number {
return gw.branches.reduce((s, b) => s + b.probability, 0)
}
export function isXorSumValid(gw: GatewayConfig): boolean {
if (gw.gatewayType !== 'exclusive') return true
return Math.abs(sumBranches(gw) - 1.0) <= XOR_SUM_TOLERANCE
}
export function GatewayPanel({ selectedElementId }: GatewayPanelProps) {
const { gateways, currentProcess, updateGateway, activities } = useProcessStore()
const [localGw, setLocalGw] = useState<GatewayConfig | null>(null)
const [isDirty, setIsDirty] = useState(false)
// Mapa bpmnElementId → nombre legible (parseamos el XML una sola vez)
const elementNames = useMemo(() => {
if (!currentProcess) return new Map<string, string>()
try {
const graph = parseBpmnXml(currentProcess.bpmnXml)
return new Map(Array.from(graph.nodes.values()).map((n) => [n.id, n.name || n.id]))
} catch {
return new Map<string, string>()
}
}, [currentProcess?.bpmnXml])
const gateway = gateways.find((g) => g.bpmnElementId === selectedElementId) ?? null
useEffect(() => {
setLocalGw(gateway ? structuredClone(gateway) : null)
setIsDirty(false)
}, [selectedElementId, gateways])
function targetLabel(targetId: string): string {
// Primero buscar en actividades (tienen nombre más descriptivo)
const act = activities.find((a) => a.bpmnElementId === targetId)
if (act?.name) return act.name
// Luego en el mapa del grafo
const fromGraph = elementNames.get(targetId)
if (fromGraph) return fromGraph
return targetId
}
function setProbability(branchIndex: number, value: number) {
if (!localGw) return
const updated = structuredClone(localGw)
updated.branches[branchIndex].probability = value
setLocalGw(updated)
setIsDirty(true)
}
function distributeUniformly() {
if (!localGw) return
const n = localGw.branches.length
const equalProb = parseFloat((1 / n).toFixed(4))
const updated = structuredClone(localGw)
updated.branches = updated.branches.map((b, i) => ({
...b,
probability: i === n - 1 ? parseFloat((1 - equalProb * (n - 1)).toFixed(4)) : equalProb,
}))
setLocalGw(updated)
setIsDirty(true)
}
async function handleSave() {
if (!localGw) return
await updateGateway(localGw)
setIsDirty(false)
}
// ── Empty state ──────────────────────────────────────────────────────────
if (!selectedElementId) {
return (
<div className="flex flex-col items-center justify-center h-full text-center px-6 py-12">
<div className="w-12 h-12 bg-slate-100 rounded-full flex items-center justify-center mb-3">
<GitMerge className="h-6 w-6 text-slate-400" />
</div>
<p className="text-sm font-medium text-slate-600 mb-1">Seleccioná un gateway</p>
<p className="text-xs text-slate-400">Hacé click sobre un gateway en el diagrama para configurar sus probabilidades</p>
</div>
)
}
if (!localGw) {
return (
<div className="flex items-center justify-center h-full">
<p className="text-xs text-slate-400">Gateway sin configuración</p>
</div>
)
}
// ── AND Paralelo: informativo, sin sliders ────────────────────────────────
if (localGw.gatewayType === 'parallel') {
return (
<ScrollArea className="h-full">
<div className="p-4 space-y-4">
<div>
<div className="flex items-center gap-2 mb-1">
<span className="text-xs font-semibold text-slate-500 uppercase tracking-wide">Gateway Paralelo (AND)</span>
</div>
<p className="text-xs font-medium text-slate-800 font-mono">{localGw.bpmnElementId}</p>
</div>
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3 space-y-2">
<div className="flex items-start gap-2">
<Info className="h-4 w-4 text-blue-500 shrink-0 mt-0.5" />
<div>
<p className="text-xs font-semibold text-blue-800">Ejecución paralela</p>
<p className="text-xs text-blue-700 mt-0.5">
En un gateway AND todas las salidas se ejecutan simultáneamente.
No hay probabilidades configurables la probabilidad de cada rama
es igual a la probabilidad de llegada al gateway.
</p>
</div>
</div>
</div>
<div className="space-y-2">
<Label className="text-xs font-medium">Flujos de salida</Label>
{localGw.branches.map((branch) => (
<div key={branch.flowId} className="flex items-center justify-between p-2.5 rounded-lg bg-slate-50 border border-slate-100">
<div className="min-w-0">
<p className="text-xs font-medium text-slate-700 truncate">{targetLabel(branch.targetElementId)}</p>
<p className="text-xs text-slate-400 font-mono">{branch.flowId}</p>
</div>
<span className="text-xs font-mono font-semibold text-blue-600 shrink-0 ml-2">100%</span>
</div>
))}
</div>
</div>
</ScrollArea>
)
}
// ── XOR Exclusivo / OR Inclusivo (con aviso) ──────────────────────────────
const isInclusive = localGw.gatewayType === 'inclusive'
const currentSum = sumBranches(localGw)
const xorValid = isInclusive || Math.abs(currentSum - 1.0) <= XOR_SUM_TOLERANCE
const sumColor = xorValid ? 'text-green-600' : 'text-destructive'
return (
<ScrollArea className="h-full">
<div className="p-4 space-y-4">
{/* Header */}
<div>
<span className="text-xs font-semibold text-slate-500 uppercase tracking-wide block mb-1">
{isInclusive ? 'Gateway Inclusivo (OR)' : 'Gateway Exclusivo (XOR)'}
</span>
<p className="text-xs font-medium text-slate-800 font-mono">{localGw.bpmnElementId}</p>
</div>
{/* Aviso para OR inclusivo */}
{isInclusive && (
<div className="bg-amber-50 border border-amber-200 rounded-lg p-3 flex items-start gap-2">
<AlertTriangle className="h-4 w-4 text-amber-500 shrink-0 mt-0.5" />
<p className="text-xs text-amber-800">
<span className="font-semibold">Gateway Inclusivo (OR)</span> En el MVP se simula como
XOR: solo una rama se toma por instancia. Las probabilidades deben sumar 1.
El soporte OR nativo llega en V1.0.
</p>
</div>
)}
{/* Suma actual + botón distribuir */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-1.5">
<span className="text-xs text-slate-500">Suma de probabilidades:</span>
<span className={`text-xs font-mono font-bold ${sumColor}`}>
{formatPercent(currentSum * 100, 1)}
</span>
{!xorValid && (
<Tooltip>
<TooltipTrigger asChild>
<AlertTriangle className="h-3.5 w-3.5 text-destructive cursor-help" />
</TooltipTrigger>
<TooltipContent side="right" className="max-w-44 text-xs">
La suma debe ser exactamente 100% para poder simular
</TooltipContent>
</Tooltip>
)}
{xorValid && (
<Tooltip>
<TooltipTrigger asChild>
<Check className="h-3.5 w-3.5 text-green-500 cursor-help" />
</TooltipTrigger>
<TooltipContent side="right" className="text-xs">
Suma 100% listo para simular
</TooltipContent>
</Tooltip>
)}
</div>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-7 text-xs gap-1 text-slate-500"
onClick={distributeUniformly}
>
<RotateCcw className="h-3 w-3" />
Distribuir uniformemente
</Button>
</TooltipTrigger>
<TooltipContent side="left" className="max-w-52 text-xs">
Reparte el 100% en partes iguales entre todas las ramas del gateway
</TooltipContent>
</Tooltip>
</div>
{/* Sliders por rama */}
<div className="space-y-4">
{localGw.branches.map((branch, idx) => (
<div key={branch.flowId} className="space-y-1.5">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<p className="text-xs font-medium text-slate-700 truncate leading-tight">
{targetLabel(branch.targetElementId)}
</p>
<p className="text-[10px] text-slate-400 font-mono truncate">{branch.flowId}</p>
</div>
<span className="text-xs font-mono font-semibold text-slate-700 shrink-0 w-11 text-right tabular-nums">
{formatPercent(branch.probability * 100, 1)}
</span>
</div>
<Slider
value={[Math.round(branch.probability * 1000) / 10]}
min={0}
max={100}
step={1}
onValueChange={([v]) => setProbability(idx, parseFloat((v / 100).toFixed(4)))}
/>
</div>
))}
</div>
{/* Error de suma — banner prominente */}
{!xorValid && (
<div className="bg-destructive/10 border border-destructive/30 rounded-lg p-3 flex items-start gap-2">
<AlertTriangle className="h-4 w-4 text-destructive shrink-0 mt-0.5" />
<p className="text-xs text-destructive">
La suma es <strong>{formatPercent(currentSum * 100, 1)}</strong> debe ser 100%.
Ajustá los sliders o usá "Distribuir" para repartir uniformemente.
El botón <strong>Simular</strong> quedará deshabilitado hasta corregirlo.
</p>
</div>
)}
<Button
size="sm"
onClick={handleSave}
disabled={!isDirty}
className="w-full"
>
{isDirty ? 'Guardar probabilidades' : 'Sin cambios'}
</Button>
</div>
</ScrollArea>
)
}

View File

@@ -0,0 +1,140 @@
import { useState, useEffect } from 'react'
import { Info } from 'lucide-react'
import { Label } from '@/components/ui/label'
import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button'
import { Slider } from '@/components/ui/slider'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { useProcessStore } from '@/store/process-store'
import { formatPercent } from '@/lib/format'
const CURRENCIES = [
{ code: 'USD', label: 'USD — Dólar estadounidense' },
{ code: 'PYG', label: 'PYG — Guaraní paraguayo' },
{ code: 'BRL', label: 'BRL — Real brasileño' },
{ code: 'ARS', label: 'ARS — Peso argentino' },
{ code: 'EUR', label: 'EUR — Euro' },
{ code: 'COP', label: 'COP — Peso colombiano' },
{ code: 'MXN', label: 'MXN — Peso mexicano' },
{ code: 'CLP', label: 'CLP — Peso chileno' },
]
export function GlobalSettingsPanel() {
const { currentProcess, updateProcess } = useProcessStore()
const [localName, setLocalName] = useState('')
const [localClient, setLocalClient] = useState('')
const [localCurrency, setLocalCurrency] = useState('USD')
const [localOverhead, setLocalOverhead] = useState(20)
const [isDirty, setIsDirty] = useState(false)
useEffect(() => {
if (!currentProcess) return
setLocalName(currentProcess.name)
setLocalClient(currentProcess.clientName)
setLocalCurrency(currentProcess.currency)
setLocalOverhead(Math.round(currentProcess.overheadPercentage * 100))
setIsDirty(false)
}, [currentProcess])
function markDirty() { setIsDirty(true) }
async function handleSave() {
await updateProcess({
name: localName.trim() || 'Proceso sin nombre',
clientName: localClient.trim(),
currency: localCurrency,
overheadPercentage: localOverhead / 100,
})
setIsDirty(false)
}
if (!currentProcess) return null
return (
<ScrollArea className="h-full">
<div className="p-4 space-y-5">
{/* Nombre del proceso */}
<div className="space-y-1.5">
<Label htmlFor="proc-name" className="text-xs font-medium">Nombre del proceso</Label>
<Input
id="proc-name"
value={localName}
onChange={(e) => { setLocalName(e.target.value); markDirty() }}
placeholder="ej. Proceso de ventas"
className="h-8 text-sm"
/>
</div>
{/* Cliente */}
<div className="space-y-1.5">
<Label htmlFor="client-name" className="text-xs font-medium">Cliente</Label>
<Input
id="client-name"
value={localClient}
onChange={(e) => { setLocalClient(e.target.value); markDirty() }}
placeholder="ej. Empresa ABC"
className="h-8 text-sm"
/>
</div>
{/* Moneda */}
<div className="space-y-1.5">
<Label className="text-xs font-medium">Moneda</Label>
<Select value={localCurrency} onValueChange={(v) => { setLocalCurrency(v); markDirty() }}>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{CURRENCIES.map((c) => (
<SelectItem key={c.code} value={c.code} className="text-xs">{c.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Overhead */}
<div className="space-y-2">
<div className="flex items-center gap-1.5">
<Label className="text-xs font-medium">Overhead (costos indirectos)</Label>
<Tooltip>
<TooltipTrigger asChild>
<Info className="h-3 w-3 text-slate-400 cursor-help" />
</TooltipTrigger>
<TooltipContent side="right" className="max-w-52 text-xs">
Porcentaje adicional sobre los costos directos que representa los costos indirectos: alquiler, administración, infraestructura general.
</TooltipContent>
</Tooltip>
</div>
<div className="flex items-center gap-3">
<Slider
value={[localOverhead]}
min={0}
max={100}
step={1}
onValueChange={([v]) => { setLocalOverhead(v); markDirty() }}
className="flex-1"
/>
<span className="text-sm font-mono font-semibold text-slate-700 w-12 text-right">
{formatPercent(localOverhead, 0)}
</span>
</div>
<p className="text-xs text-slate-400">
Típicamente entre 15% y 40% para empresas de servicios
</p>
</div>
{/* Guardar */}
<Button
size="sm"
onClick={handleSave}
disabled={!isDirty}
className="w-full"
>
{isDirty ? 'Guardar configuración' : 'Sin cambios'}
</Button>
</div>
</ScrollArea>
)
}

View File

@@ -0,0 +1,201 @@
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 { 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 { formatCurrency } from '@/lib/format'
import type { Resource, ResourceType } from '@/domain/types'
const RESOURCE_TYPE_LABELS: Record<ResourceType, string> = {
role: 'Rol',
person: 'Persona',
system: 'Sistema',
equipment: 'Equipo',
supply: 'Insumo',
}
const RESOURCE_TYPE_COLORS: Record<ResourceType, string> = {
role: 'bg-blue-100 text-blue-700',
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() {
const { currentProcess, resources, addResource, updateResource, deleteResource } = useProcessStore()
const [showForm, setShowForm] = useState(false)
const [editingId, setEditingId] = useState<string | null>(null)
const [form, setForm] = useState<ResourceFormState>(EMPTY_FORM)
function startAdd() {
setForm(EMPTY_FORM)
setEditingId(null)
setShowForm(true)
}
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 })
} else {
const newResource: Resource = {
id: uuidv4(),
processId: currentProcess.id,
name: form.name.trim(),
type: form.type,
costPerHour,
}
await addResource(newResource)
}
cancelForm()
}
return (
<ScrollArea className="h-full">
<div className="p-4 space-y-4">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<p className="text-xs font-medium text-slate-700">Recursos del proceso</p>
<p className="text-xs text-slate-400">{resources.length} definido{resources.length !== 1 ? 's' : ''}</p>
</div>
{!showForm && (
<Button size="sm" variant="outline" onClick={startAdd} className="h-7 text-xs gap-1">
<PlusCircle className="h-3.5 w-3.5" />
Nuevo
</Button>
)}
</div>
{/* Formulario */}
{showForm && (
<div className="border border-primary/20 bg-primary/5 rounded-lg p-3 space-y-3">
<p className="text-xs font-semibold text-slate-700">{editingId ? 'Editar recurso' : 'Nuevo recurso'}</p>
<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 }))}
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)}
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
</div>
))}
</div>
)}
</div>
</ScrollArea>
)
}

View File

@@ -0,0 +1,293 @@
import { useEffect, useRef, useState, useCallback, useMemo } from 'react'
import { useParams, useNavigate } from '@tanstack/react-router'
import {
Loader2, BarChart3, Play, AlertTriangle,
PanelRightClose, PanelRightOpen, Home, GitMerge, MousePointer2
} from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Tooltip, TooltipContent, TooltipTrigger, TooltipProvider } from '@/components/ui/tooltip'
import { Separator } from '@/components/ui/separator'
import { useToast } from '@/components/ui/use-toast'
import { BpmnCanvas, type BpmnElementCategory } from './BpmnCanvas'
import { ActivityPanel } from './ActivityPanel'
import { GatewayPanel, isXorSumValid } from './GatewayPanel'
import { ResourcesPanel } from './ResourcesPanel'
import { GlobalSettingsPanel } from './GlobalSettingsPanel'
import { useProcessStore } from '@/store/process-store'
import { useSimulationStore } from '@/store/simulation-store'
import { useSimulate } from '../simulation/useSimulate'
type SelectedCategory = 'activity' | 'gateway' | null
export function WorkspacePage() {
const { processId } = useParams({ from: '/workspace/$processId' })
const navigate = useNavigate()
const { currentProcess, isLoading, error, loadProcess, gateways } = useProcessStore()
const { isRunning, result, error: simError, selectActivity } = useSimulationStore()
const { simulate } = useSimulate()
const { toast } = useToast()
const [sidebarOpen, setSidebarOpen] = useState(true)
const [selectedElementId, setSelectedElementId] = useState<string | null>(null)
const [selectedElementName, setSelectedElementName] = useState<string>('')
const [selectedCategory, setSelectedCategory] = useState<SelectedCategory>(null)
const [activeTab, setActiveTab] = useState('elemento')
// Usamos un ref para saber si loadProcess ya terminó al menos una vez.
// Esto evita que el redirect se dispare en el render inicial donde
// isLoading=false y currentProcess=null (antes del primer efecto).
const hasLoadedOnce = useRef(false)
useEffect(() => { loadProcess(processId) }, [processId, loadProcess])
useEffect(() => {
if (isLoading) { hasLoadedOnce.current = true; return }
if (!hasLoadedOnce.current) return // carga no iniciada aún
if ((error || !currentProcess) && processId) {
toast({
title: 'Proceso no encontrado',
description: 'El proceso que buscás no existe o fue eliminado.',
variant: 'destructive',
})
navigate({ to: '/' })
}
}, [isLoading, error, currentProcess, processId, toast, navigate])
// Validación XOR: todos los gateways exclusivos deben sumar 1.0
const xorValidation = useMemo(() => {
const invalidIds = gateways
.filter((gw) => gw.gatewayType === 'exclusive' && !isXorSumValid(gw))
.map((gw) => gw.bpmnElementId)
return { valid: invalidIds.length === 0, invalidIds }
}, [gateways])
const canSimulate = !isRunning && xorValidation.valid
const handleElementClick = useCallback((
elementId: string,
elementName: string,
category: BpmnElementCategory
) => {
if (category === 'other') return
setSelectedElementId(elementId)
setSelectedElementName(elementName)
setSelectedCategory(category)
selectActivity(category === 'activity' ? elementId : null)
setActiveTab('elemento')
if (!sidebarOpen) setSidebarOpen(true)
}, [selectActivity, sidebarOpen])
function clearSelection() {
setSelectedElementId(null)
setSelectedElementName('')
setSelectedCategory(null)
selectActivity(null)
}
async function handleSimulate() {
if (!canSimulate) return
const simResult = await simulate()
if (simResult) navigate({ to: '/report/$processId', params: { processId } })
}
if (isLoading) {
return (
<div className="min-h-screen flex items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
</div>
)
}
if (error || !currentProcess) {
return null // el useEffect ya inició el redirect con toast
}
// Label del tab "elemento" según lo seleccionado
const elementTabLabel = selectedCategory === 'gateway' ? 'Gateway' : 'Actividad'
const elementTabIcon = selectedCategory === 'gateway'
? <GitMerge className="h-3 w-3 mr-1" />
: <MousePointer2 className="h-3 w-3 mr-1" />
return (
<TooltipProvider>
<div className="h-screen flex flex-col bg-slate-100 overflow-hidden">
{/* ── Topbar ─────────────────────────────────────────────────── */}
<header className="h-12 bg-white border-b border-slate-200 flex items-center px-3 gap-3 shrink-0 z-10">
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => navigate({ to: '/' })}>
<Home className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Inicio</TooltipContent>
</Tooltip>
<Separator orientation="vertical" className="h-5" />
<div className="flex-1 min-w-0">
<p className="text-sm font-semibold text-slate-800 truncate">{currentProcess.name}</p>
{currentProcess.clientName && (
<p className="text-xs text-slate-400 truncate">{currentProcess.clientName}</p>
)}
</div>
{/* Errores de simulación */}
{simError && (
<div className="flex items-center gap-1.5 text-destructive text-xs bg-destructive/10 px-2 py-1 rounded">
<AlertTriangle className="h-3.5 w-3.5 shrink-0" />
{simError}
</div>
)}
{/* Advertencia de XOR inválido */}
{!xorValidation.valid && (
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center gap-1.5 text-amber-700 text-xs bg-amber-50 border border-amber-200 px-2 py-1 rounded cursor-help">
<AlertTriangle className="h-3.5 w-3.5 shrink-0" />
{xorValidation.invalidIds.length} gateway{xorValidation.invalidIds.length > 1 ? 's' : ''} con probabilidades inválidas
</div>
</TooltipTrigger>
<TooltipContent side="bottom" className="max-w-60 text-xs">
<p className="font-semibold mb-1">Gateways XOR que no suman 100%:</p>
{xorValidation.invalidIds.map((id) => (
<p key={id} className="font-mono"> {id}</p>
))}
<p className="mt-1 text-slate-300">Ajustá las probabilidades para habilitar la simulación</p>
</TooltipContent>
</Tooltip>
)}
{/* Ver reporte si ya hay simulación */}
{result && (
<Button
variant="outline"
size="sm"
className="h-8 text-xs gap-1.5 text-primary border-primary/30"
onClick={() => navigate({ to: '/report/$processId', params: { processId } })}
>
<BarChart3 className="h-3.5 w-3.5" />
Ver reporte
</Button>
)}
{/* Simular */}
<Tooltip>
<TooltipTrigger asChild>
<span>
<Button
size="sm"
className="h-8 text-xs gap-1.5"
onClick={handleSimulate}
disabled={!canSimulate}
>
{isRunning
? <><Loader2 className="h-3.5 w-3.5 animate-spin" />Simulando</>
: <><Play className="h-3.5 w-3.5" />Simular</>
}
</Button>
</span>
</TooltipTrigger>
{!xorValidation.valid && (
<TooltipContent side="bottom" className="text-xs">
Corregí las probabilidades de los gateways XOR primero
</TooltipContent>
)}
</Tooltip>
<Separator orientation="vertical" className="h-5" />
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => setSidebarOpen((v) => !v)}
>
{sidebarOpen ? <PanelRightClose className="h-4 w-4" /> : <PanelRightOpen className="h-4 w-4" />}
</Button>
</TooltipTrigger>
<TooltipContent>{sidebarOpen ? 'Cerrar panel' : 'Abrir panel'}</TooltipContent>
</Tooltip>
</header>
{/* ── Main ───────────────────────────────────────────────────── */}
<div className="flex-1 flex overflow-hidden relative">
{/* Canvas BPMN */}
<div className="flex-1 relative overflow-hidden bpmn-container">
<BpmnCanvas
xml={currentProcess.bpmnXml}
onElementClick={handleElementClick}
selectedElementId={selectedElementId}
className="absolute inset-0"
/>
{/* Chip de elemento seleccionado */}
{selectedElementId && (
<div className="absolute top-3 left-3 bg-white/90 backdrop-blur-sm border border-slate-200 rounded-lg px-3 py-1.5 shadow-sm flex items-center gap-2 max-w-xs">
<div className={`w-2 h-2 rounded-full shrink-0 ${selectedCategory === 'gateway' ? 'bg-amber-500' : 'bg-primary'}`} />
<span className="text-xs font-medium text-slate-700 truncate">{selectedElementName || selectedElementId}</span>
<button onClick={clearSelection} className="ml-1 text-slate-400 hover:text-slate-600 shrink-0">×</button>
</div>
)}
{/* Hint inicial */}
{!selectedElementId && (
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 bg-white/80 backdrop-blur-sm border border-slate-200 rounded-full px-4 py-2 shadow-sm">
<p className="text-xs text-slate-500">Hacé click en una tarea o gateway para configurarlo</p>
</div>
)}
</div>
{/* Panel lateral */}
<div className={`
shrink-0 bg-white border-l border-slate-200 flex flex-col overflow-hidden
transition-all duration-300 ease-in-out
${sidebarOpen ? 'w-72' : 'w-0'}
`}>
{sidebarOpen && (
<Tabs value={activeTab} onValueChange={setActiveTab} className="flex flex-col h-full">
<TabsList className="shrink-0 mx-3 mt-3 grid grid-cols-3 w-auto">
<TabsTrigger value="elemento" className="text-xs flex items-center">
{elementTabIcon}{elementTabLabel}
</TabsTrigger>
<TabsTrigger value="recursos" className="text-xs">Recursos</TabsTrigger>
<TabsTrigger value="global" className="text-xs">Global</TabsTrigger>
</TabsList>
<TabsContent value="elemento" className="flex-1 overflow-hidden mt-0 data-[state=active]:flex data-[state=active]:flex-col">
{selectedCategory === 'gateway'
? <GatewayPanel selectedElementId={selectedElementId} />
: <ActivityPanel selectedElementId={selectedElementId} />
}
</TabsContent>
<TabsContent value="recursos" className="flex-1 overflow-hidden mt-0 data-[state=active]:flex data-[state=active]:flex-col">
<ResourcesPanel />
</TabsContent>
<TabsContent value="global" className="flex-1 overflow-hidden mt-0 data-[state=active]:flex data-[state=active]:flex-col">
<GlobalSettingsPanel />
</TabsContent>
</Tabs>
)}
</div>
{/* Toggle flotante cuando sidebar cerrado */}
{!sidebarOpen && (
<button
onClick={() => setSidebarOpen(true)}
className="absolute right-0 top-1/2 -translate-y-1/2 bg-white border border-slate-200 border-r-0 rounded-l-lg p-1.5 shadow-md hover:bg-slate-50 z-20"
>
<PanelRightOpen className="h-4 w-4 text-slate-500" />
</button>
)}
</div>
</div>
</TooltipProvider>
)
}

242
src/lib/chart-options.ts Normal file
View File

@@ -0,0 +1,242 @@
import { activityColor, type HeatmapMode } from './colors'
import { formatCurrency } from './format'
import type { ActivitySimResult, Resource, ResourceType, SimulationResult } from '@/domain/types'
// Nombres y colores por tipo de recurso — única fuente de verdad
export const RESOURCE_TYPE_LABELS: Record<ResourceType, string> = {
role: 'Roles', person: 'Personas', system: 'Sistemas',
equipment: 'Equipos', supply: 'Insumos',
}
export const RESOURCE_TYPE_COLORS: Record<ResourceType, string> = {
role: '#3b82f6', person: '#10b981', system: '#8b5cf6',
equipment: '#f59e0b', supply: '#64748b',
}
function truncate(s: string, max: number): string {
return s.length > max ? s.slice(0, max - 1) + '…' : s
}
// ─── Top actividades por costo — barras horizontales ─────────────────────────
export function buildTopActivitiesOption(
perActivity: ActivitySimResult[],
mode: HeatmapMode,
currency: string
): Record<string, unknown> {
const top10 = perActivity.slice(0, 10)
return {
tooltip: {
trigger: 'axis',
axisPointer: { type: 'shadow' },
formatter: (params: unknown[]) => {
const p = params[0] as { dataIndex: number; value: number }
const act = top10[p.dataIndex]
return `<div style="font-size:12px">
<div style="font-weight:600;margin-bottom:4px">${act.activityName}</div>
<div>Costo esperado: <strong>${formatCurrency(act.expectedTotalCost, currency)}</strong></div>
<div>% del total: ${act.percentOfTotal.toFixed(1)}%</div>
<div>Directo: ${formatCurrency(act.expectedDirectCost, currency)}</div>
<div>Indirecto: ${formatCurrency(act.expectedIndirectCost, currency)}</div>
</div>`
},
},
grid: { left: 12, right: 24, top: 8, bottom: 8, containLabel: true },
xAxis: {
type: 'value',
axisLabel: {
fontSize: 11,
formatter: (v: number) => {
if (v === 0) return '0'
if (v >= 1_000_000) return `${(v / 1_000_000).toFixed(1)}M`
if (v >= 1_000) return `${(v / 1_000).toFixed(0)}k`
return String(v)
},
},
splitLine: { lineStyle: { color: '#f1f5f9' } },
},
yAxis: {
type: 'category',
data: top10.map((a) => truncate(a.activityName, 28)),
inverse: true,
axisLabel: { fontSize: 11, color: '#475569', width: 160, overflow: 'truncate' },
axisTick: { show: false },
axisLine: { show: false },
},
series: [{
type: 'bar',
data: top10.map((a) => ({
value: a.expectedTotalCost,
itemStyle: { color: activityColor(a, perActivity, mode) },
})),
barMaxWidth: 28,
label: {
show: true,
position: 'right',
fontSize: 10,
color: '#64748b',
formatter: (p: { value: number }) => formatCurrency(p.value, currency),
},
}],
}
}
// ─── Composición directo / indirecto — donut ──────────────────────────────────
export function buildCostCompositionOption(
result: Pick<SimulationResult, 'totalDirectCost' | 'totalIndirectCost' | 'totalCost'>,
currency: string
): Record<string, unknown> {
const hasIndirect = result.totalIndirectCost > 0
return {
tooltip: {
trigger: 'item',
formatter: (p: { name: string; value: number; percent: number }) =>
`<div style="font-size:12px">
<div style="font-weight:600;margin-bottom:4px">${p.name}</div>
<div>${formatCurrency(p.value, currency)} (${p.percent.toFixed(1)}%)</div>
</div>`,
},
legend: {
bottom: '2%',
left: 'center',
itemWidth: 12,
itemHeight: 12,
textStyle: { fontSize: 11, color: '#475569' },
},
series: [{
type: 'pie',
radius: ['48%', '68%'],
center: ['50%', '44%'],
avoidLabelOverlap: true,
itemStyle: { borderRadius: 4, borderColor: '#fff', borderWidth: 2 },
label: {
show: true,
fontSize: 11,
formatter: (p: { percent: number }) => `${p.percent.toFixed(1)}%`,
},
labelLine: { length: 10, length2: 8 },
emphasis: { label: { fontSize: 13, fontWeight: 'bold' } },
data: [
{ value: result.totalDirectCost, name: 'Costo directo', itemStyle: { color: '#3b82f6' } },
...(hasIndirect ? [{ value: result.totalIndirectCost, name: 'Costo indirecto (overhead)', itemStyle: { color: '#94a3b8' } }] : []),
],
}],
graphic: [{
type: 'text',
left: 'center',
top: '38%',
style: {
text: formatCurrency(result.totalCost, currency),
textAlign: 'center',
fontSize: 13,
fontWeight: 'bold',
fill: '#1e293b',
fontFamily: 'JetBrains Mono, monospace',
},
}],
}
}
// ─── Costo por tipo de recurso — barras verticales ────────────────────────────
interface ResourceTypeTotals {
byType: Partial<Record<ResourceType, number>>
byTypeDetails: Partial<Record<ResourceType, { name: string; cost: number }[]>>
activeTypes: ResourceType[]
}
export function aggregateResourceCosts(
perActivity: ActivitySimResult[],
resources: Resource[]
): ResourceTypeTotals {
const resourceById = new Map(resources.map((r) => [r.id, r]))
const byType: Partial<Record<ResourceType, number>> = {}
const byTypeDetails: Partial<Record<ResourceType, { name: string; cost: number }[]>> = {}
for (const act of perActivity) {
for (const rb of act.resourceCostBreakdown) {
if (rb.cost <= 0) continue
const res = resourceById.get(rb.resourceId)
if (!res) continue
byType[res.type] = (byType[res.type] ?? 0) + rb.cost
if (!byTypeDetails[res.type]) byTypeDetails[res.type] = []
const existing = byTypeDetails[res.type]!.find((d) => d.name === res.name)
if (existing) existing.cost += rb.cost
else byTypeDetails[res.type]!.push({ name: res.name, cost: rb.cost })
}
}
const activeTypes = (Object.keys(RESOURCE_TYPE_LABELS) as ResourceType[]).filter(
(t) => (byType[t] ?? 0) > 0
)
return { byType, byTypeDetails, activeTypes }
}
export function buildCostByResourceOption(
perActivity: ActivitySimResult[],
resources: Resource[],
currency: string
): Record<string, unknown> | null {
const { byType, byTypeDetails, activeTypes } = aggregateResourceCosts(perActivity, resources)
if (activeTypes.length === 0) return null
return {
tooltip: {
trigger: 'axis',
axisPointer: { type: 'shadow' },
formatter: (params: unknown[]) => {
const p = params[0] as { dataIndex: number; value: number }
const type = activeTypes[p.dataIndex]
const dets = byTypeDetails[type] ?? []
const detHtml = dets
.sort((a, b) => b.cost - a.cost)
.map((d) => `<div style="font-size:11px;padding-left:8px">• ${d.name}: ${formatCurrency(d.cost, currency)}</div>`)
.join('')
return `<div style="font-size:12px">
<div style="font-weight:600;margin-bottom:4px">${RESOURCE_TYPE_LABELS[type]}</div>
<div>Total: <strong>${formatCurrency(p.value, currency)}</strong></div>${detHtml}
</div>`
},
},
grid: { left: 12, right: 12, top: 12, bottom: 8, containLabel: true },
xAxis: {
type: 'category',
data: activeTypes.map((t) => RESOURCE_TYPE_LABELS[t]),
axisLabel: { fontSize: 11, color: '#475569' },
axisTick: { show: false },
axisLine: { lineStyle: { color: '#e2e8f0' } },
},
yAxis: {
type: 'value',
axisLabel: {
fontSize: 10,
color: '#94a3b8',
formatter: (v: number) => {
if (v >= 1_000_000) return `${(v / 1_000_000).toFixed(1)}M`
if (v >= 1_000) return `${(v / 1_000).toFixed(0)}k`
return String(v)
},
},
splitLine: { lineStyle: { color: '#f1f5f9' } },
},
series: [{
type: 'bar',
data: activeTypes.map((t) => ({
value: byType[t] ?? 0,
itemStyle: { color: RESOURCE_TYPE_COLORS[t], borderRadius: [4, 4, 0, 0] },
})),
barMaxWidth: 48,
label: {
show: true,
position: 'top',
fontSize: 10,
color: '#64748b',
formatter: (p: { value: number }) => formatCurrency(p.value, currency),
},
}],
}
}

149
src/lib/colors.ts Normal file
View File

@@ -0,0 +1,149 @@
// Paleta heatmap: verde → amarillo → rojo
const HEATMAP_LOW = { r: 16, g: 185, b: 129 } // #10b981
const HEATMAP_MID = { r: 245, g: 158, b: 11 } // #f59e0b
const HEATMAP_HIGH = { r: 239, g: 68, b: 68 } // #ef4444
function lerp(a: number, b: number, t: number): number {
return Math.round(a + (b - a) * t)
}
// t en [0, 1]
export function heatmapColor(t: number): string {
const clamped = Math.max(0, Math.min(1, t))
let r: number, g: number, b: number
if (clamped <= 0.5) {
const localT = clamped * 2
r = lerp(HEATMAP_LOW.r, HEATMAP_MID.r, localT)
g = lerp(HEATMAP_LOW.g, HEATMAP_MID.g, localT)
b = lerp(HEATMAP_LOW.b, HEATMAP_MID.b, localT)
} else {
const localT = (clamped - 0.5) * 2
r = lerp(HEATMAP_MID.r, HEATMAP_HIGH.r, localT)
g = lerp(HEATMAP_MID.g, HEATMAP_HIGH.g, localT)
b = lerp(HEATMAP_MID.b, HEATMAP_HIGH.b, localT)
}
return `rgb(${r}, ${g}, ${b})`
}
export function heatmapColorHex(t: number): string {
const clamped = Math.max(0, Math.min(1, t))
let r: number, g: number, b: number
if (clamped <= 0.5) {
const localT = clamped * 2
r = lerp(HEATMAP_LOW.r, HEATMAP_MID.r, localT)
g = lerp(HEATMAP_LOW.g, HEATMAP_MID.g, localT)
b = lerp(HEATMAP_LOW.b, HEATMAP_MID.b, localT)
} else {
const localT = (clamped - 0.5) * 2
r = lerp(HEATMAP_MID.r, HEATMAP_HIGH.r, localT)
g = lerp(HEATMAP_MID.g, HEATMAP_HIGH.g, localT)
b = lerp(HEATMAP_MID.b, HEATMAP_HIGH.b, localT)
}
return `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`
}
// Retorna opacidad para colores con alpha (útil para overlays sobre bpmn-js)
export function heatmapColorWithAlpha(t: number, alpha = 0.75): string {
const clamped = Math.max(0, Math.min(1, t))
let r: number, g: number, b: number
if (clamped <= 0.5) {
const localT = clamped * 2
r = lerp(HEATMAP_LOW.r, HEATMAP_MID.r, localT)
g = lerp(HEATMAP_LOW.g, HEATMAP_MID.g, localT)
b = lerp(HEATMAP_LOW.b, HEATMAP_MID.b, localT)
} else {
const localT = (clamped - 0.5) * 2
r = lerp(HEATMAP_MID.r, HEATMAP_HIGH.r, localT)
g = lerp(HEATMAP_MID.g, HEATMAP_HIGH.g, localT)
b = lerp(HEATMAP_MID.b, HEATMAP_HIGH.b, localT)
}
return `rgba(${r}, ${g}, ${b}, ${alpha})`
}
// Determina si el texto sobre el color debe ser claro u oscuro (WCAG contrast)
export function getContrastText(t: number): 'white' | 'black' {
return t > 0.5 ? 'white' : 'black'
}
// ─── Helpers para el heatmap del reporte ──────────────────────────────────────
export type HeatmapMode = 'relative' | 'absolute'
export interface ActivityCostData {
expectedTotalCost: number
percentOfTotal: number
}
// Color neutro (gris claro) cuando no hay varianza significativa entre actividades.
// Evita comunicar una falsa diferenciación cuando todos los costos son similares.
export const NEUTRAL_HEATMAP_COLOR = '#cbd5e1' // slate-300
// Umbral: si el rango relativo (max-min)/max < 5%, la varianza no es significativa.
export const VARIANCE_THRESHOLD = 0.05
export function hasSignificantCostVariance(activities: ActivityCostData[]): boolean {
if (activities.length < 2) return false
const costs = activities.map((a) => a.expectedTotalCost)
const max = Math.max(...costs)
const min = Math.min(...costs)
if (max <= 0) return false
return (max - min) / max > VARIANCE_THRESHOLD
}
// Calcula t ∈ [0,1] para una actividad dada la lista completa y el modo.
// Modo relativo: normaliza sobre la actividad más costosa (t=1 = la que más pesa).
// Modo absoluto: normaliza sobre el rango min-max del proceso.
// Pre-condición: llamar solo cuando hasSignificantCostVariance = true.
export function computeActivityT(
activity: ActivityCostData,
allActivities: ActivityCostData[],
mode: HeatmapMode
): number {
if (allActivities.length === 0) return 0
if (mode === 'relative') {
const maxPercent = Math.max(...allActivities.map((a) => a.percentOfTotal), 0.001)
return Math.max(0, Math.min(1, activity.percentOfTotal / maxPercent))
}
const costs = allActivities.map((a) => a.expectedTotalCost)
const minCost = Math.min(...costs)
const maxCost = Math.max(...costs)
if (maxCost <= minCost) return 0.5
return Math.max(0, Math.min(1, (activity.expectedTotalCost - minCost) / (maxCost - minCost)))
}
// Función unificada: devuelve el color correcto considerando varianza.
// Usar en HeatmapCanvas, ActivitiesTable y los gráficos para consistencia.
export function activityColor(
activity: ActivityCostData,
allActivities: ActivityCostData[],
mode: HeatmapMode
): string {
if (!hasSignificantCostVariance(allActivities)) return NEUTRAL_HEATMAP_COLOR
return heatmapColorHex(computeActivityT(activity, allActivities, mode))
}
// Valores de los extremos para la leyenda del heatmap
export function heatmapLegendBounds(
allActivities: ActivityCostData[],
mode: HeatmapMode
): { min: number; mid: number; max: number; unit: 'currency' | 'percent' } {
if (allActivities.length === 0) return { min: 0, mid: 50, max: 100, unit: 'percent' }
if (mode === 'relative') {
const maxPercent = Math.max(...allActivities.map((a) => a.percentOfTotal))
return { min: 0, mid: maxPercent / 2, max: maxPercent, unit: 'percent' }
}
const costs = allActivities.map((a) => a.expectedTotalCost)
const minCost = Math.min(...costs)
const maxCost = Math.max(...costs)
return { min: minCost, mid: (minCost + maxCost) / 2, max: maxCost, unit: 'currency' }
}

View File

@@ -0,0 +1,115 @@
import Papa from 'papaparse'
import { formatSimulationTimestamp } from '@/lib/format'
import { buildFileName } from './slug'
import type { Process, Simulation, Resource, ActivitySimResult } from '@/domain/types'
// Monedas que usan coma como separador decimal (necesitan punto y coma en el CSV)
const COMMA_DECIMAL_CURRENCIES = new Set(['PYG', 'BRL', 'EUR', 'ARS', 'COP', 'MXN', 'CLP'])
function getDelimiter(currency: string): string {
return COMMA_DECIMAL_CURRENCIES.has(currency) ? ';' : ','
}
function formatDecimalForCsv(value: number, currency: string): string {
const formatted = value.toFixed(2)
if (COMMA_DECIMAL_CURRENCIES.has(currency)) {
return formatted.replace('.', ',')
}
return formatted
}
function buildResourcesCell(
act: ActivitySimResult,
resources: Resource[]
): string {
if (act.resourceCostBreakdown.length === 0) return ''
const resourceById = new Map(resources.map((r) => [r.id, r]))
return act.resourceCostBreakdown
.map((rb) => {
const res = resourceById.get(rb.resourceId)
if (!res) return rb.resourceName
// Calcular % de utilización desde el costo relativo
return res.name
})
.join('; ')
}
export interface CsvExportParams {
process: Process
simulation: Simulation
resources: Resource[]
}
export function generateCsvContent(params: CsvExportParams): string {
const { process, simulation, resources } = params
const { currency } = process
const result = simulation.result
const delimiter = getDelimiter(currency)
const { date } = formatSimulationTimestamp(simulation.executedAt)
// ── Filas de metadata como comentarios ──────────────────────────────────
const meta = [
`# Proceso: ${process.name}`,
`# Cliente: ${process.clientName || 'N/A'}`,
`# Fecha simulación: ${date}`,
`# Moneda: ${currency}`,
`# Costo total (${currency}): ${formatDecimalForCsv(result.totalCost, currency)}`,
`# Costo directo: ${formatDecimalForCsv(result.totalDirectCost, currency)}`,
`# Costo indirecto (overhead ${(process.overheadPercentage * 100).toFixed(0)}%): ${formatDecimalForCsv(result.totalIndirectCost, currency)}`,
'',
].join('\r\n')
// ── Headers ──────────────────────────────────────────────────────────────
const headers = [
'ID BPMN',
'Actividad',
'Tipo',
`Costo directo (${currency})`,
`Costo indirecto (${currency})`,
`Costo total (${currency})`,
'% del total',
'Tiempo (min)',
'Ejecuciones esperadas',
'Recursos asignados',
]
// ── Filas ─────────────────────────────────────────────────────────────────
const rows = result.perActivity.map((act) => [
act.bpmnElementId,
act.activityName,
act.bpmnElementId.includes('subprocess') ? 'subproceso' : 'tarea',
formatDecimalForCsv(act.expectedDirectCost, currency),
formatDecimalForCsv(act.expectedIndirectCost, currency),
formatDecimalForCsv(act.expectedTotalCost, currency),
formatDecimalForCsv(act.percentOfTotal, currency),
formatDecimalForCsv(act.executionTimeMinutes, currency),
formatDecimalForCsv(act.executionProbability * 100, currency),
buildResourcesCell(act, resources),
])
const csvBody = Papa.unparse(
{ fields: headers, data: rows },
{ delimiter, newline: '\r\n', quotes: true }
)
// BOM UTF-8 + metadata + datos
return '' + meta + csvBody
}
export function exportToCsv(params: CsvExportParams): void {
const content = generateCsvContent(params)
const fileName = buildFileName(
params.process.name,
params.process.clientName,
new Date(params.simulation.executedAt),
'csv'
)
const blob = new Blob([content], { type: 'text/csv;charset=utf-8' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = fileName
a.click()
URL.revokeObjectURL(url)
}

View File

@@ -0,0 +1,131 @@
import { buildFileName } from './slug'
import {
drawHeader, drawKpiCards, drawHeatmapImage, drawHeatmapLegend,
drawAnalysisSection, drawMethodologySection, addPageNumbers, PDF,
} from './pdf-sections'
import type { Process, Simulation, Resource } from '@/domain/types'
export interface PdfExportParams {
process: Process
simulation: Simulation
resources: Resource[]
heatmapImageData: string | null // data URL de la imagen capturada del canvas
}
export async function exportToPdf(params: PdfExportParams): Promise<void> {
const { process, simulation, resources, heatmapImageData } = params
const result = simulation.result
const currency = process.currency
// Carga dinámica: jsPDF + autotable solo se descargan al hacer click
const [{ jsPDF }, { default: autoTable }] = await Promise.all([
import('jspdf'),
import('jspdf-autotable'),
])
const doc = new jsPDF({ orientation: 'portrait', unit: 'mm', format: 'a4' })
// Metadata del PDF
doc.setProperties({
title: `Análisis de costos - ${process.name}`,
author: process.clientName || '',
subject: 'Simulación de proceso BPMN',
creator: 'Process Cost Platform',
keywords: 'bpmn, costos, simulación',
})
// ── PÁGINA 1: Header + KPIs + Heatmap ────────────────────────────────────
let y = drawHeader(doc as any, process, simulation.executedAt)
y = drawKpiCards(doc as any, result, currency, y)
y += 4
// Título del heatmap
doc.setFont('helvetica', 'bold')
doc.setFontSize(10)
doc.setTextColor(...PDF.slate900)
doc.text('Mapa de calor de costos', PDF.margin, y)
y += 5
y = drawHeatmapImage(doc as any, heatmapImageData, y)
y = drawHeatmapLegend(doc as any, y)
y += 4
// ── PÁGINA 2: Análisis ────────────────────────────────────────────────────
doc.addPage()
y = PDF.margin
y = drawAnalysisSection(doc as any, result, currency, y)
y += 6
// ── PÁGINA 3+: Tabla detalle (autotable maneja paginación automática) ─────
void resources // disponible para uso futuro (recursos en tabla de actividades)
const RA = 'right' as const
const tableHead = [
[
{ content: 'Actividad', styles: { cellWidth: 48 } },
{ content: `Dir. (${currency})`, styles: { halign: RA, cellWidth: 25 } },
{ content: `Indir. (${currency})`, styles: { halign: RA, cellWidth: 25 } },
{ content: `Total (${currency})`, styles: { halign: RA, cellWidth: 28 } },
{ content: '% total', styles: { halign: RA, cellWidth: 16 } },
{ content: 'Tiempo', styles: { halign: RA, cellWidth: 14 } },
{ content: 'Prob.', styles: { halign: RA, cellWidth: 14 } },
],
]
const tableBody = result.perActivity.map((act) => [
{ content: act.activityName },
{ content: formatNum(act.expectedDirectCost), styles: { halign: RA, font: 'courier' } },
{ content: formatNum(act.expectedIndirectCost), styles: { halign: RA, font: 'courier' } },
{ content: formatNum(act.expectedTotalCost), styles: { halign: RA, font: 'courier' } },
{ content: `${act.percentOfTotal.toFixed(1)}%`, styles: { halign: RA, font: 'courier' } },
{ content: act.executionTimeMinutes > 0 ? formatTime(act.executionTimeMinutes) : '—', styles: { halign: RA } },
{ content: `${(act.executionProbability * 100).toFixed(0)}%`, styles: { halign: RA } },
])
autoTable(doc, {
head: tableHead,
body: tableBody,
startY: y,
margin: { left: PDF.margin, right: PDF.margin },
styles: { fontSize: 8, cellPadding: 2.5, overflow: 'ellipsize' },
headStyles: {
fillColor: PDF.blue,
textColor: [255, 255, 255],
fontStyle: 'bold',
fontSize: 8,
},
alternateRowStyles: { fillColor: PDF.slate50 },
columnStyles: { 0: { cellWidth: 48 } },
didDrawPage: () => {
// Placeholder — los footers se añaden al final con addPageNumbers
},
})
// ── PÁGINA FINAL: Metodología ────────────────────────────────────────────
doc.addPage()
drawMethodologySection(doc as any, process, PDF.margin)
// ── FOOTER en todas las páginas ───────────────────────────────────────────
addPageNumbers(doc as any, 'Process Cost Platform', simulation.executedAt)
// ── Descargar ─────────────────────────────────────────────────────────────
const fileName = buildFileName(
process.name,
process.clientName,
new Date(simulation.executedAt),
'pdf'
)
doc.save(fileName)
}
function formatNum(n: number): string {
return n.toLocaleString('es-PY', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
}
function formatTime(minutes: number): string {
if (minutes < 60) return `${Math.round(minutes)}m`
const h = Math.floor(minutes / 60)
const m = Math.round(minutes % 60)
return m > 0 ? `${h}h ${m}m` : `${h}h`
}

View File

@@ -0,0 +1,291 @@
import { formatCurrency, formatMinutes, formatSimulationTimestamp } from '@/lib/format'
import type { SimulationResult, Process } from '@/domain/types'
// Unidades: mm. A4 = 210 × 297mm, márgenes 20mm, área útil 170 × 257mm
export const PDF = {
pageW: 210, pageH: 297,
margin: 20,
contentW: 170,
blue: [30, 64, 175] as [number, number, number],
slate900: [15, 23, 42] as [number, number, number],
slate700: [51, 65, 85] as [number, number, number],
slate500: [100, 116, 139] as [number, number, number],
slate200: [226, 232, 240] as [number, number, number],
slate50: [248, 250, 252] as [number, number, number],
heatLow: [16, 185, 129] as [number, number, number],
heatMid: [245, 158, 11] as [number, number, number],
heatHigh: [239, 68, 68] as [number, number, number],
}
// Tipo mínimo de doc para los helpers (evitar importar jsPDF en este archivo)
type DocLike = {
setFont: (name: string, style: string) => void
setFontSize: (size: number) => void
setTextColor: (...args: number[]) => void
setFillColor: (...args: number[]) => void
setDrawColor: (...args: number[]) => void
setLineWidth: (w: number) => void
text: (text: string | string[], x: number, y: number, opts?: Record<string, unknown>) => void
rect: (x: number, y: number, w: number, h: number, style?: string) => void
roundedRect: (x: number, y: number, w: number, h: number, rx: number, ry: number, style?: string) => void
line: (x1: number, y1: number, x2: number, y2: number) => void
addImage: (img: string, format: string, x: number, y: number, w: number, h: number) => void
addPage: () => void
setPage: (page: number) => void
internal: { getNumberOfPages: () => number }
splitTextToSize: (text: string, maxWidth: number) => string[]
}
export function drawHeader(doc: DocLike, process: Process, simulatedAt: number): number {
let y = PDF.margin
// Nombre del proceso
doc.setFont('helvetica', 'bold')
doc.setFontSize(20)
doc.setTextColor(...PDF.slate900)
doc.text(process.name, PDF.margin, y)
y += 8
// Cliente + fecha + moneda
const { date, time } = formatSimulationTimestamp(simulatedAt)
const subtitle = [
process.clientName ? `Cliente: ${process.clientName}` : null,
`Simulado el ${date} a las ${time}`,
`Moneda: ${process.currency}`,
].filter(Boolean).join(' · ')
doc.setFont('helvetica', 'normal')
doc.setFontSize(9)
doc.setTextColor(...PDF.slate500)
doc.text(subtitle, PDF.margin, y)
y += 5
// Línea separadora
doc.setDrawColor(...PDF.slate200)
doc.setLineWidth(0.3)
doc.line(PDF.margin, y, PDF.margin + PDF.contentW, y)
y += 5
return y
}
export function drawKpiCards(
doc: DocLike,
result: SimulationResult,
currency: string,
startY: number
): number {
const cardW = 80, cardH = 24, gap = 10
const col1 = PDF.margin, col2 = PDF.margin + cardW + gap
const cards = [
{ label: 'COSTO TOTAL ESPERADO', value: formatCurrency(result.totalCost, currency), accent: true },
{ label: 'COSTO DIRECTO / OVERHEAD', value: `${formatCurrency(result.totalDirectCost, currency)} / ${formatCurrency(result.totalIndirectCost, currency)}`, accent: false },
{ label: 'TIEMPO TOTAL ESPERADO', value: formatMinutes(result.totalTimeMinutes), accent: false },
{ label: 'ACTIVIDADES COSTEADAS', value: String(result.perActivity.length), accent: false },
]
let maxY = startY
cards.forEach((card, idx) => {
const x = idx % 2 === 0 ? col1 : col2
const y = idx < 2 ? startY : startY + cardH + 4
// Fondo
doc.setFillColor(...PDF.slate50)
doc.roundedRect(x, y, cardW, cardH, 2, 2, 'F')
// Borde
doc.setDrawColor(...PDF.slate200)
doc.setLineWidth(0.2)
doc.roundedRect(x, y, cardW, cardH, 2, 2, 'S')
// Label pequeño
doc.setFont('helvetica', 'normal')
doc.setFontSize(6.5)
doc.setTextColor(...PDF.slate500)
doc.text(card.label, x + 4, y + 6)
// Valor grande
doc.setFont('helvetica', 'bold')
doc.setFontSize(card.value.length > 16 ? 9 : 12)
doc.setTextColor(card.accent ? PDF.blue[0] : PDF.slate900[0], card.accent ? PDF.blue[1] : PDF.slate900[1], card.accent ? PDF.blue[2] : PDF.slate900[2])
doc.text(card.value, x + 4, y + 16)
maxY = Math.max(maxY, y + cardH)
})
return maxY + 6
}
export function drawHeatmapImage(
doc: DocLike,
imageDataUrl: string | null,
startY: number
): number {
if (!imageDataUrl) return startY
// Calcular altura proporcional (asumiendo aspect ratio ~2:1 del canvas BPMN)
const imgW = PDF.contentW
const imgH = Math.min(imgW / 2.2, 80) // máximo 80mm de alto
const x = PDF.margin
if (startY + imgH > PDF.pageH - PDF.margin - 10) {
doc.addPage()
startY = PDF.margin
}
// Borde sutil
doc.setDrawColor(...PDF.slate200)
doc.setLineWidth(0.2)
doc.rect(x, startY, imgW, imgH, 'S')
doc.addImage(imageDataUrl, 'JPEG', x, startY, imgW, imgH)
return startY + imgH + 4
}
export function drawHeatmapLegend(doc: DocLike, startY: number): number {
const x = PDF.margin, barW = PDF.contentW, barH = 4
const labelY = startY + barH + 3.5
doc.setFontSize(7)
doc.setFont('helvetica', 'normal')
// Etiqueta izquierda
doc.setTextColor(...PDF.heatLow)
doc.text('Bajo costo', x, labelY)
// Barra de gradiente: dibujada como N segmentos de colores
const steps = 40
const segW = barW / steps
for (let i = 0; i < steps; i++) {
const t = i / (steps - 1)
let r: number, g: number, b: number
if (t <= 0.5) {
const lt = t * 2
r = Math.round(PDF.heatLow[0] + (PDF.heatMid[0] - PDF.heatLow[0]) * lt)
g = Math.round(PDF.heatLow[1] + (PDF.heatMid[1] - PDF.heatLow[1]) * lt)
b = Math.round(PDF.heatLow[2] + (PDF.heatMid[2] - PDF.heatLow[2]) * lt)
} else {
const lt = (t - 0.5) * 2
r = Math.round(PDF.heatMid[0] + (PDF.heatHigh[0] - PDF.heatMid[0]) * lt)
g = Math.round(PDF.heatMid[1] + (PDF.heatHigh[1] - PDF.heatMid[1]) * lt)
b = Math.round(PDF.heatMid[2] + (PDF.heatHigh[2] - PDF.heatMid[2]) * lt)
}
doc.setFillColor(r, g, b)
doc.rect(x + i * segW, startY, segW + 0.1, barH, 'F')
}
// Etiqueta derecha
doc.setTextColor(...PDF.heatHigh)
doc.text('Alto costo', x + barW, labelY, { align: 'right' } as any)
return labelY + 3
}
export function drawAnalysisSection(
doc: DocLike,
result: SimulationResult,
currency: string,
startY: number
): number {
let y = startY
doc.setFont('helvetica', 'bold')
doc.setFontSize(12)
doc.setTextColor(...PDF.slate900)
doc.text('Análisis de composición', PDF.margin, y)
y += 6
doc.setDrawColor(...PDF.slate200)
doc.setLineWidth(0.2)
doc.line(PDF.margin, y, PDF.margin + PDF.contentW, y)
y += 5
doc.setFont('helvetica', 'normal')
doc.setFontSize(9)
doc.setTextColor(...PDF.slate700)
// Composición directo/indirecto
const directPct = result.totalCost > 0
? ((result.totalDirectCost / result.totalCost) * 100).toFixed(1)
: '0'
const indirectPct = result.totalCost > 0
? ((result.totalIndirectCost / result.totalCost) * 100).toFixed(1)
: '0'
doc.text(`Composición del costo: ${directPct}% directo (${formatCurrency(result.totalDirectCost, currency)}) · ${indirectPct}% overhead (${formatCurrency(result.totalIndirectCost, currency)})`, PDF.margin, y)
y += 6
// Top 3 actividades
const top3 = result.perActivity.slice(0, 3)
const top3Text = top3.map((a, i) => `${i + 1}. ${a.activityName} (${a.percentOfTotal.toFixed(1)}% — ${formatCurrency(a.expectedTotalCost, currency)})`).join(' ')
const lines = doc.splitTextToSize(`Top 3 actividades por costo: ${top3Text}`, PDF.contentW)
doc.text(lines, PDF.margin, y)
y += lines.length * 4.5 + 2
// Recurso más costoso (si hay)
if (result.perResource.length > 0) {
const topResource = [...result.perResource].sort((a, b) => b.totalCost - a.totalCost)[0]
doc.text(`Recurso más costoso: "${topResource.resourceName}" con ${formatCurrency(topResource.totalCost, currency)} en total`, PDF.margin, y)
y += 5
} else {
doc.setTextColor(...PDF.slate500)
doc.text('Sin recursos asignados (costos son fijos por actividad)', PDF.margin, y)
doc.setTextColor(...PDF.slate700)
y += 5
}
return y + 2
}
export function drawMethodologySection(
doc: DocLike,
process: Process,
startY: number
): number {
let y = startY
doc.setFont('helvetica', 'bold')
doc.setFontSize(10)
doc.setTextColor(...PDF.slate900)
doc.text('Nota metodológica', PDF.margin, y)
y += 5
doc.setFont('helvetica', 'normal')
doc.setFontSize(8)
doc.setTextColor(...PDF.slate500)
const lines = [
`Costo total = Σ(actividades) costo_directo_esperado + costo_indirecto_esperado.`,
`Costo directo por actividad = (costo_fijo + Σ(recurso × costo_hora × tiempo × utilización / 60)) × probabilidad_ejecución.`,
`Costo indirecto = costo_directo_total × overhead_global (${(process.overheadPercentage * 100).toFixed(0)}%).`,
`Probabilidades de ejecución calculadas mediante propagación hacia adelante desde el StartEvent,`,
`considerando gateways XOR y AND configurados. Modelo determinístico agregado — MVP Fase 0.`,
]
for (const line of lines) {
const wrapped = doc.splitTextToSize(line, PDF.contentW)
doc.text(wrapped, PDF.margin, y)
y += wrapped.length * 4
}
return y
}
export function addPageNumbers(doc: DocLike, platformName: string, generatedAt: number): void {
const total = doc.internal.getNumberOfPages()
const { date } = formatSimulationTimestamp(generatedAt)
for (let i = 1; i <= total; i++) {
doc.setPage(i)
doc.setFont('helvetica', 'normal')
doc.setFontSize(7)
doc.setTextColor(180, 180, 180)
doc.text(
`${platformName} · ${date} · Página ${i} de ${total}`,
105,
PDF.pageH - 8,
{ align: 'center' } as any
)
}
}

28
src/lib/export/slug.ts Normal file
View File

@@ -0,0 +1,28 @@
export function slugify(text: string): string {
return text
.normalize('NFD')
.replace(/[̀-ͯ]/g, '') // eliminar diacríticos (á→a, ñ→n, ü→u)
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-') // todo lo no-alfanumérico → guión
.replace(/^-+|-+$/g, '') // recortar guiones del inicio/fin
.slice(0, 60) // limitar longitud para nombres de archivo
}
export function buildFileName(
processName: string,
clientName: string | undefined,
date: Date,
ext: 'pdf' | 'csv'
): string {
const dateStr = [
date.getFullYear(),
String(date.getMonth() + 1).padStart(2, '0'),
String(date.getDate()).padStart(2, '0'),
].join('')
const parts = [slugify(processName)]
if (clientName?.trim()) parts.push(slugify(clientName.trim()))
parts.push(dateStr)
return `${parts.join('_')}.${ext}`
}

36
src/lib/format.ts Normal file
View File

@@ -0,0 +1,36 @@
export function formatCurrency(amount: number, currency = 'USD'): string {
return new Intl.NumberFormat('es-PY', {
style: 'currency',
currency,
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(amount)
}
export function formatNumber(n: number, decimals = 1): string {
return new Intl.NumberFormat('es-PY', {
minimumFractionDigits: decimals,
maximumFractionDigits: decimals,
}).format(n)
}
export function formatPercent(n: number, decimals = 1): string {
return `${formatNumber(n, decimals)}%`
}
export function formatMinutes(minutes: number): string {
if (minutes < 60) return `${Math.round(minutes)} min`
const h = Math.floor(minutes / 60)
const m = Math.round(minutes % 60)
return m > 0 ? `${h}h ${m}min` : `${h}h`
}
// Formato de timestamp de simulación: "13 de mayo de 2026 a las 09:45"
// Locale explícito es-PY — nunca depende del locale del browser del cliente.
export function formatSimulationTimestamp(ts: number): { date: string; time: string } {
const d = new Date(ts)
return {
date: d.toLocaleDateString('es-PY', { day: '2-digit', month: 'long', year: 'numeric' }),
time: d.toLocaleTimeString('es-PY', { hour: '2-digit', minute: '2-digit' }),
}
}

6
src/lib/utils.ts Normal file
View File

@@ -0,0 +1,6 @@
import { type ClassValue, clsx } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

10
src/main.tsx Normal file
View File

@@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import '@/styles/globals.css'
import { App } from './App'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)

24
src/persistence/db.ts Normal file
View File

@@ -0,0 +1,24 @@
import Dexie, { type Table } from 'dexie'
import type { Process, Activity, GatewayConfig, Resource, Simulation } from '@/domain/types'
export class ProcessCostDb extends Dexie {
processes!: Table<Process>
activities!: Table<Activity>
gateways!: Table<GatewayConfig>
resources!: Table<Resource>
simulations!: Table<Simulation>
constructor() {
super('ProcessCostPlatform')
this.version(1).stores({
processes: 'id, name, updatedAt',
activities: 'id, processId, bpmnElementId',
gateways: 'id, processId, bpmnElementId',
resources: 'id, processId, name',
simulations: 'id, processId, executedAt',
})
}
}
export const db = new ProcessCostDb()

View File

@@ -0,0 +1,114 @@
import { db } from './db'
import type { Process, Activity, GatewayConfig, Resource, Simulation } from '@/domain/types'
// ─── Process ─────────────────────────────────────────────────────────────────
export const processRepo = {
async save(process: Process): Promise<void> {
await db.processes.put(process)
},
async getById(id: string): Promise<Process | undefined> {
return db.processes.get(id)
},
async getAll(): Promise<Process[]> {
return db.processes.orderBy('updatedAt').reverse().toArray()
},
async delete(id: string): Promise<void> {
await db.transaction('rw', [db.processes, db.activities, db.gateways, db.resources, db.simulations], async () => {
await db.processes.delete(id)
await db.activities.where('processId').equals(id).delete()
await db.gateways.where('processId').equals(id).delete()
await db.resources.where('processId').equals(id).delete()
await db.simulations.where('processId').equals(id).delete()
})
},
}
// ─── Activity ─────────────────────────────────────────────────────────────────
export const activityRepo = {
async save(activity: Activity): Promise<void> {
await db.activities.put(activity)
},
async saveMany(activities: Activity[]): Promise<void> {
await db.activities.bulkPut(activities)
},
async getByProcess(processId: string): Promise<Activity[]> {
return db.activities.where('processId').equals(processId).toArray()
},
async getByBpmnElementId(processId: string, bpmnElementId: string): Promise<Activity | undefined> {
return db.activities
.where('[processId+bpmnElementId]')
.equals([processId, bpmnElementId])
.first()
},
async deleteByProcess(processId: string): Promise<void> {
await db.activities.where('processId').equals(processId).delete()
},
}
// ─── GatewayConfig ────────────────────────────────────────────────────────────
export const gatewayRepo = {
async save(gateway: GatewayConfig): Promise<void> {
await db.gateways.put(gateway)
},
async saveMany(gateways: GatewayConfig[]): Promise<void> {
await db.gateways.bulkPut(gateways)
},
async getByProcess(processId: string): Promise<GatewayConfig[]> {
return db.gateways.where('processId').equals(processId).toArray()
},
async deleteByProcess(processId: string): Promise<void> {
await db.gateways.where('processId').equals(processId).delete()
},
}
// ─── Resource ─────────────────────────────────────────────────────────────────
export const resourceRepo = {
async save(resource: Resource): Promise<void> {
await db.resources.put(resource)
},
async getByProcess(processId: string): Promise<Resource[]> {
return db.resources.where('processId').equals(processId).toArray()
},
async delete(id: string): Promise<void> {
await db.resources.delete(id)
},
async deleteByProcess(processId: string): Promise<void> {
await db.resources.where('processId').equals(processId).delete()
},
}
// ─── Simulation ───────────────────────────────────────────────────────────────
export const simulationRepo = {
async save(simulation: Simulation): Promise<void> {
await db.simulations.put(simulation)
},
async getLatestByProcess(processId: string): Promise<Simulation | undefined> {
return db.simulations
.where('processId')
.equals(processId)
.last()
},
async getByProcess(processId: string): Promise<Simulation[]> {
return db.simulations.where('processId').equals(processId).toArray()
},
}

51
src/router.tsx Normal file
View File

@@ -0,0 +1,51 @@
import { lazy, Suspense } from 'react'
import { createRouter, createRootRoute, createRoute, Outlet } from '@tanstack/react-router'
import { WorkspacePage } from '@/features/workspace/WorkspacePage'
import { ImportPage } from '@/features/import/ImportPage'
import { Loader2 } from 'lucide-react'
// ECharts es grande (373KB gzip). Lazy-load del ReportPage para que el
// bundle inicial no lo incluya — se carga solo cuando el usuario llega al reporte.
const ReportPage = lazy(() =>
import('@/features/report/ReportPage').then((m) => ({ default: m.ReportPage }))
)
function ReportPageWithSuspense() {
return (
<Suspense fallback={
<div className="min-h-screen flex flex-col items-center justify-center gap-3 bg-slate-50">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
<p className="text-sm text-slate-400">Cargando reporte</p>
</div>
}>
<ReportPage />
</Suspense>
)
}
const rootRoute = createRootRoute({ component: () => <Outlet /> })
const indexRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/',
component: ImportPage,
})
const workspaceRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/workspace/$processId',
component: WorkspacePage,
})
const reportRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/report/$processId',
component: ReportPageWithSuspense,
})
const routeTree = rootRoute.addChildren([indexRoute, workspaceRoute, reportRoute])
export const router = createRouter({ routeTree })
declare module '@tanstack/react-router' {
interface Register { router: typeof router }
}

View File

@@ -0,0 +1,99 @@
import { create } from 'zustand'
import { processRepo, activityRepo, gatewayRepo, resourceRepo } from '@/persistence/repositories'
import type { Process, Activity, GatewayConfig, Resource } from '@/domain/types'
interface ProcessState {
currentProcess: Process | null
activities: Activity[]
gateways: GatewayConfig[]
resources: Resource[]
isLoading: boolean
error: string | null
// Acciones
loadProcess: (processId: string) => Promise<void>
setCurrentProcess: (process: Process) => Promise<void>
updateProcess: (updates: Partial<Process>) => Promise<void>
updateActivity: (activity: Activity) => Promise<void>
updateGateway: (gateway: GatewayConfig) => Promise<void>
addResource: (resource: Resource) => Promise<void>
updateResource: (resource: Resource) => Promise<void>
deleteResource: (resourceId: string) => Promise<void>
reset: () => void
}
export const useProcessStore = create<ProcessState>((set, get) => ({
currentProcess: null,
activities: [],
gateways: [],
resources: [],
isLoading: false,
error: null,
loadProcess: async (processId) => {
set({ isLoading: true, error: null })
try {
const [process, activities, gateways, resources] = await Promise.all([
processRepo.getById(processId),
activityRepo.getByProcess(processId),
gatewayRepo.getByProcess(processId),
resourceRepo.getByProcess(processId),
])
if (!process) throw new Error('Proceso no encontrado')
set({ currentProcess: process, activities, gateways, resources, isLoading: false })
} catch (err) {
set({ error: err instanceof Error ? err.message : 'Error desconocido', isLoading: false })
}
},
setCurrentProcess: async (process) => {
await processRepo.save(process)
const [activities, gateways, resources] = await Promise.all([
activityRepo.getByProcess(process.id),
gatewayRepo.getByProcess(process.id),
resourceRepo.getByProcess(process.id),
])
set({ currentProcess: process, activities, gateways, resources })
},
updateProcess: async (updates) => {
const current = get().currentProcess
if (!current) return
const updated: Process = { ...current, ...updates, updatedAt: Date.now() }
await processRepo.save(updated)
set({ currentProcess: updated })
},
updateActivity: async (activity) => {
await activityRepo.save(activity)
set((state) => ({
activities: state.activities.map((a) => (a.id === activity.id ? activity : a)),
}))
},
updateGateway: async (gateway) => {
await gatewayRepo.save(gateway)
set((state) => ({
gateways: state.gateways.map((g) => (g.id === gateway.id ? gateway : g)),
}))
},
addResource: async (resource) => {
await resourceRepo.save(resource)
set((state) => ({ resources: [...state.resources, resource] }))
},
updateResource: async (resource) => {
await resourceRepo.save(resource)
set((state) => ({
resources: state.resources.map((r) => (r.id === resource.id ? resource : r)),
}))
},
deleteResource: async (resourceId) => {
await resourceRepo.delete(resourceId)
set((state) => ({ resources: state.resources.filter((r) => r.id !== resourceId) }))
},
reset: () => set({ currentProcess: null, activities: [], gateways: [], resources: [], error: null }),
}))

View File

@@ -0,0 +1,28 @@
import { create } from 'zustand'
import type { SimulationResult } from '@/domain/types'
interface SimulationState {
result: SimulationResult | null
isRunning: boolean
error: string | null
selectedActivityId: string | null
setResult: (result: SimulationResult) => void
setRunning: (running: boolean) => void
setError: (error: string | null) => void
selectActivity: (bpmnElementId: string | null) => void
reset: () => void
}
export const useSimulationStore = create<SimulationState>((set) => ({
result: null,
isRunning: false,
error: null,
selectedActivityId: null,
setResult: (result) => set({ result, error: null }),
setRunning: (isRunning) => set({ isRunning }),
setError: (error) => set({ error }),
selectActivity: (selectedActivityId) => set({ selectedActivityId }),
reset: () => set({ result: null, isRunning: false, error: null, selectedActivityId: null }),
}))

92
src/styles/globals.css Normal file
View File

@@ -0,0 +1,92 @@
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap');
@import 'bpmn-js/dist/assets/diagram-js.css';
@import 'bpmn-js/dist/assets/bpmn-js.css';
@import 'bpmn-js/dist/assets/bpmn-font/css/bpmn.css';
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%;
--primary: 221.2 83.2% 53.3%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 221.2 83.2% 53.3%;
--radius: 0.5rem;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground font-sans;
font-feature-settings: "rlig" 1, "calt" 1;
}
.font-mono {
font-family: 'JetBrains Mono', monospace;
}
}
/* ── bpmn-js canvas ─────────────────────────────────────────── */
.bpmn-container .djs-container {
background: #f8fafc; /* slate-50 */
}
/* Elemento seleccionado desde nuestro código */
.bpmn-container .bpmn-selected .djs-visual > :first-child {
stroke: hsl(var(--primary)) !important;
stroke-width: 2.5px !important;
}
/* Elemento clickeable: cursor pointer en tasks */
.bpmn-container .djs-element[data-element-id] {
cursor: default;
}
/* Hover visual en tasks */
.bpmn-container .djs-shape:hover .djs-visual > rect,
.bpmn-container .djs-shape:hover .djs-visual > path {
filter: brightness(0.97);
}
/* heatmap overlays — para la Etapa 5 */
.heatmap-overlay {
pointer-events: none;
transition: fill 0.3s ease;
}
/* Asegurar que bpmn-js ocupa todo su contenedor */
.bpmn-container .djs-container svg {
width: 100% !important;
height: 100% !important;
}
/* Cursor pointer sobre tasks en el heatmap del reporte */
.bpmn-container .djs-shape[data-element-id] .djs-hit {
cursor: default;
}
/* Selección en el heatmap del reporte — stroke más prominente */
.bpmn-container .bpmn-selected .djs-visual > rect {
stroke: hsl(var(--primary)) !important;
stroke-width: 3px !important;
stroke-dasharray: none !important;
}