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:
14
src/App.tsx
Normal file
14
src/App.tsx
Normal 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
|
||||
9
src/components/AppFooter.tsx
Normal file
9
src/components/AppFooter.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
30
src/components/ui/badge.tsx
Normal file
30
src/components/ui/badge.tsx
Normal 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 }
|
||||
48
src/components/ui/button.tsx
Normal file
48
src/components/ui/button.tsx
Normal 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 }
|
||||
55
src/components/ui/card.tsx
Normal file
55
src/components/ui/card.tsx
Normal 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 }
|
||||
21
src/components/ui/input.tsx
Normal file
21
src/components/ui/input.tsx
Normal 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 }
|
||||
18
src/components/ui/label.tsx
Normal file
18
src/components/ui/label.tsx
Normal 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 }
|
||||
43
src/components/ui/scroll-area.tsx
Normal file
43
src/components/ui/scroll-area.tsx
Normal 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 }
|
||||
147
src/components/ui/select.tsx
Normal file
147
src/components/ui/select.tsx
Normal 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,
|
||||
}
|
||||
23
src/components/ui/separator.tsx
Normal file
23
src/components/ui/separator.tsx
Normal 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 }
|
||||
22
src/components/ui/slider.tsx
Normal file
22
src/components/ui/slider.tsx
Normal 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 }
|
||||
56
src/components/ui/table.tsx
Normal file
56
src/components/ui/table.tsx
Normal 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 }
|
||||
52
src/components/ui/tabs.tsx
Normal file
52
src/components/ui/tabs.tsx
Normal 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 }
|
||||
98
src/components/ui/toast.tsx
Normal file
98
src/components/ui/toast.tsx
Normal 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,
|
||||
}
|
||||
20
src/components/ui/toaster.tsx
Normal file
20
src/components/ui/toaster.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
27
src/components/ui/tooltip.tsx
Normal file
27
src/components/ui/tooltip.tsx
Normal 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 }
|
||||
70
src/components/ui/use-toast.ts
Normal file
70
src/components/ui/use-toast.ts
Normal 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
153
src/domain/bpmn-parser.ts
Normal 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
54
src/domain/schemas.ts
Normal 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
255
src/domain/simulation.ts
Normal 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
175
src/domain/types.ts
Normal 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'
|
||||
}
|
||||
}
|
||||
277
src/features/import/ImportPage.tsx
Normal file
277
src/features/import/ImportPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
119
src/features/import/SampleProcessLoader.tsx
Normal file
119
src/features/import/SampleProcessLoader.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
133
src/features/report/ActivitiesTable.tsx
Normal file
133
src/features/report/ActivitiesTable.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
28
src/features/report/CostByResourceChart.tsx
Normal file
28
src/features/report/CostByResourceChart.tsx
Normal 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 />
|
||||
}
|
||||
18
src/features/report/CostCompositionChart.tsx
Normal file
18
src/features/report/CostCompositionChart.tsx
Normal 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 />
|
||||
}
|
||||
279
src/features/report/HeatmapCanvas.tsx
Normal file
279
src/features/report/HeatmapCanvas.tsx
Normal 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'
|
||||
35
src/features/report/HeatmapLegend.tsx
Normal file
35
src/features/report/HeatmapLegend.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
49
src/features/report/HeatmapModeToggle.tsx
Normal file
49
src/features/report/HeatmapModeToggle.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
72
src/features/report/KpiBar.tsx
Normal file
72
src/features/report/KpiBar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
36
src/features/report/MethodologyFooter.tsx
Normal file
36
src/features/report/MethodologyFooter.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
67
src/features/report/ReportHeader.tsx
Normal file
67
src/features/report/ReportHeader.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
278
src/features/report/ReportPage.tsx
Normal file
278
src/features/report/ReportPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
24
src/features/report/TopActivitiesChart.tsx
Normal file
24
src/features/report/TopActivitiesChart.tsx
Normal 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 />
|
||||
}
|
||||
36
src/features/report/WarningsBanner.tsx
Normal file
36
src/features/report/WarningsBanner.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
54
src/features/report/useReportData.ts
Normal file
54
src/features/report/useReportData.ts
Normal 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 }
|
||||
}
|
||||
56
src/features/simulation/useSimulate.ts
Normal file
56
src/features/simulation/useSimulate.ts
Normal 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 }
|
||||
}
|
||||
241
src/features/workspace/ActivityPanel.tsx
Normal file
241
src/features/workspace/ActivityPanel.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
82
src/features/workspace/BpmnCanvas.tsx
Normal file
82
src/features/workspace/BpmnCanvas.tsx
Normal 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%' }} />
|
||||
}
|
||||
277
src/features/workspace/GatewayPanel.tsx
Normal file
277
src/features/workspace/GatewayPanel.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
140
src/features/workspace/GlobalSettingsPanel.tsx
Normal file
140
src/features/workspace/GlobalSettingsPanel.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
201
src/features/workspace/ResourcesPanel.tsx
Normal file
201
src/features/workspace/ResourcesPanel.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
293
src/features/workspace/WorkspacePage.tsx
Normal file
293
src/features/workspace/WorkspacePage.tsx
Normal 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
242
src/lib/chart-options.ts
Normal 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
149
src/lib/colors.ts
Normal 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' }
|
||||
}
|
||||
115
src/lib/export/csv-export.ts
Normal file
115
src/lib/export/csv-export.ts
Normal 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)
|
||||
}
|
||||
131
src/lib/export/pdf-export.ts
Normal file
131
src/lib/export/pdf-export.ts
Normal 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`
|
||||
}
|
||||
291
src/lib/export/pdf-sections.ts
Normal file
291
src/lib/export/pdf-sections.ts
Normal 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
28
src/lib/export/slug.ts
Normal 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
36
src/lib/format.ts
Normal 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
6
src/lib/utils.ts
Normal 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
10
src/main.tsx
Normal 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
24
src/persistence/db.ts
Normal 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()
|
||||
114
src/persistence/repositories.ts
Normal file
114
src/persistence/repositories.ts
Normal 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
51
src/router.tsx
Normal 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 }
|
||||
}
|
||||
99
src/store/process-store.ts
Normal file
99
src/store/process-store.ts
Normal 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 }),
|
||||
}))
|
||||
28
src/store/simulation-store.ts
Normal file
28
src/store/simulation-store.ts
Normal 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
92
src/styles/globals.css
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user