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

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

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

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

View File

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