Sprint 2 Etapas 1-2: modelo recursos utilizationMinutes+units, Dexie v3, ActivityPanel wow

This commit is contained in:
2026-05-19 21:26:34 -03:00
parent 49364f4b18
commit a01380240a
23 changed files with 292265 additions and 244 deletions

View File

@@ -3,7 +3,7 @@ import { PlusCircle, Trash2, Info, AlertTriangle } 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'
@@ -18,7 +18,7 @@ interface ActivityPanelProps {
}
export function ActivityPanel({ selectedElementId }: ActivityPanelProps) {
const { activities, resources, updateActivity } = useProcessStore()
const { activities, resources, updateActivity, currentProcess } = useProcessStore()
const [localActivity, setLocalActivity] = useState<Activity | null>(null)
const [isDirty, setIsDirty] = useState(false)
@@ -48,7 +48,7 @@ export function ActivityPanel({ selectedElementId }: ActivityPanelProps) {
if (already) return
handleChange('assignedResources', [
...localActivity.assignedResources,
{ resourceId, utilizationPercent: 1.0 },
{ resourceId, utilizationMinutes: 0, units: 1 },
])
}
@@ -60,12 +60,26 @@ export function ActivityPanel({ selectedElementId }: ActivityPanelProps) {
)
}
function setUtilization(resourceId: string, value: number) {
function setUtilizationMinutes(resourceId: string, minutes: number) {
if (!localActivity) return
handleChange(
'assignedResources',
localActivity.assignedResources.map((r) =>
r.resourceId === resourceId ? { ...r, utilizationPercent: value } : r
r.resourceId === resourceId
? { ...r, utilizationMinutes: Math.max(0, Math.round(minutes)) }
: r
)
)
}
function setUnits(resourceId: string, units: number) {
if (!localActivity) return
handleChange(
'assignedResources',
localActivity.assignedResources.map((r) =>
r.resourceId === resourceId
? { ...r, units: Math.max(1, Math.round(units)) }
: r
)
)
}
@@ -96,6 +110,15 @@ export function ActivityPanel({ selectedElementId }: ActivityPanelProps) {
(r) => !localActivity.assignedResources.some((ar) => ar.resourceId === r.id)
)
// Costo de recursos calculado en tiempo real (misma fórmula que simulation.ts)
const localResourceCost = localActivity.assignedResources.reduce((total, assignment) => {
const resource = resources.find((r) => r.id === assignment.resourceId)
if (!resource) return total
return total + (resource.costPerHour * assignment.utilizationMinutes / 60 * assignment.units)
}, 0)
const localTotalCost = localActivity.directCostFixed + localResourceCost
return (
<ScrollArea className="h-full">
<div className="p-4 space-y-5">
@@ -158,23 +181,35 @@ export function ActivityPanel({ selectedElementId }: ActivityPanelProps) {
{/* Recursos asignados */}
<div className="space-y-2">
<Label className="text-xs font-medium block">Recursos asignados</Label>
<div className="flex items-center gap-1.5">
<Label className="text-xs font-medium block">Recursos asignados</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">
Cada recurso aporta costo según: costo/hora × minutos por ejecución × unidades
</TooltipContent>
</Tooltip>
</div>
{localActivity.assignedResources.length === 0 ? (
<p className="text-xs text-slate-400 py-2">Sin recursos asignados</p>
<p className="text-xs text-slate-400 py-1">Sin recursos asignados</p>
) : (
<div className="space-y-3">
<div className="space-y-2">
{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)
const resourceCost = resource.costPerHour * assignment.utilizationMinutes / 60 * assignment.units
return (
<div key={assignment.resourceId} className="bg-slate-50 rounded-lg p-3 space-y-2">
<div key={assignment.resourceId} className="bg-slate-50 rounded-lg p-3 space-y-2.5">
{/* Header del recurso */}
<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>
<p className="text-xs text-slate-400 font-mono">{formatCurrency(resource.costPerHour)}/h</p>
</div>
<Button
variant="ghost"
@@ -185,18 +220,40 @@ export function ActivityPanel({ selectedElementId }: ActivityPanelProps) {
<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>
{/* Inputs: minutos y unidades */}
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1">
<Label className="text-xs text-slate-500">Minutos / ejecución</Label>
<Input
type="number"
min={0}
step={1}
value={assignment.utilizationMinutes === 0 ? '' : assignment.utilizationMinutes}
onChange={(e) => setUtilizationMinutes(assignment.resourceId, parseFloat(e.target.value) || 0)}
className="font-mono h-7 text-xs"
placeholder="0"
/>
</div>
<Slider
value={[assignment.utilizationPercent * 100]}
min={0}
max={100}
step={5}
onValueChange={([v]) => setUtilization(assignment.resourceId, v / 100)}
/>
<div className="space-y-1">
<Label className="text-xs text-slate-500">Unidades</Label>
<Input
type="number"
min={1}
step={1}
value={assignment.units}
onChange={(e) => setUnits(assignment.resourceId, parseFloat(e.target.value) || 1)}
className="font-mono h-7 text-xs"
/>
</div>
</div>
{/* Costo calculado en tiempo real */}
<div className="flex items-center justify-between pt-0.5">
<span className="text-xs text-slate-400">Costo por ejecución</span>
<span className="text-xs font-mono font-semibold text-slate-700">
{formatCurrency(resourceCost)}
</span>
</div>
</div>
)
@@ -204,7 +261,7 @@ export function ActivityPanel({ selectedElementId }: ActivityPanelProps) {
</div>
)}
{/* Agregar recurso */}
{/* Selector para agregar recurso */}
{availableResources.length > 0 && (
<Select onValueChange={addResource}>
<SelectTrigger className="h-8 text-xs">
@@ -224,10 +281,74 @@ export function ActivityPanel({ selectedElementId }: ActivityPanelProps) {
)}
{resources.length === 0 && (
<p className="text-xs text-slate-400">Creá recursos en la tab "Recursos" para asignarlos</p>
<p className="text-xs text-slate-400">
Creá recursos en la tab &quot;Recursos&quot; para asignarlos
</p>
)}
</div>
{/* ── Resumen de costos en tiempo real ─────────────────────────────── */}
{(localActivity.assignedResources.length > 0 || localActivity.directCostFixed > 0) && (
<div className="rounded-lg border border-slate-200 bg-white p-3 space-y-1.5">
<p className="text-xs font-semibold text-slate-500 uppercase tracking-wide">Costo por ejecución</p>
{localActivity.directCostFixed > 0 && (
<div className="flex justify-between">
<span className="text-xs text-slate-500">Fijo (insumos, licencias)</span>
<span className="text-xs font-mono text-slate-600">{formatCurrency(localActivity.directCostFixed)}</span>
</div>
)}
{localActivity.assignedResources.map((assignment) => {
const resource = resources.find((r) => r.id === assignment.resourceId)
if (!resource) return null
const cost = resource.costPerHour * assignment.utilizationMinutes / 60 * assignment.units
if (cost === 0) return null
return (
<div key={assignment.resourceId} className="flex justify-between">
<span className="text-xs text-slate-500">{resource.name}</span>
<span className="text-xs font-mono text-slate-600">{formatCurrency(cost)}</span>
</div>
)
})}
<div className="flex justify-between border-t border-slate-100 pt-1.5 mt-1">
<span className="text-xs font-semibold text-slate-700">Total</span>
<span className="text-xs font-mono font-bold text-[#F59845]">{formatCurrency(localTotalCost)}</span>
</div>
</div>
)}
{/* ── Helper contextual ────────────────────────────────────────────── */}
{(() => {
const annualFrequency = currentProcess?.annualFrequency ?? 0
if (annualFrequency === 0 || localActivity.assignedResources.length === 0) return null
const topAssignment = [...localActivity.assignedResources]
.map((a) => {
const r = resources.find((res) => res.id === a.resourceId)
return r ? { name: r.name, cost: r.costPerHour * a.utilizationMinutes / 60 * a.units, utilizationMinutes: a.utilizationMinutes } : null
})
.filter(Boolean)
.sort((a, b) => (b?.cost ?? 0) - (a?.cost ?? 0))[0]
if (!topAssignment || topAssignment.utilizationMinutes === 0) return null
const execsPerMonth = annualFrequency / 12
const minutesPerMonth = execsPerMonth * topAssignment.utilizationMinutes
const daysPerMonth = minutesPerMonth / 60 / 8
if (daysPerMonth < 0.1) return null
return (
<div className="flex items-start gap-2 rounded-lg bg-orange-50 border border-orange-100 px-3 py-2">
<Info className="h-3.5 w-3.5 text-[#F59845] shrink-0 mt-0.5" />
<p className="text-xs text-orange-700 leading-snug">
A {Math.round(execsPerMonth)} ejecuciones/mes, <strong>{topAssignment.name}</strong> dedica{' '}
<strong>{daysPerMonth < 1 ? `${Math.round(daysPerMonth * 8)}h` : `${daysPerMonth.toFixed(1)} días`}</strong>{' '}
al mes a esta actividad.
</p>
</div>
)
})()}
{/* ── Sección: Automatización ──────────────────────────────────── */}
<div>
<Separator className="mb-4" />