339 lines
14 KiB
TypeScript
339 lines
14 KiB
TypeScript
import { useEffect, useState } from 'react'
|
|
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'
|
|
import { Switch } from '@/components/ui/switch'
|
|
import { Separator } from '@/components/ui/separator'
|
|
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 === 0 ? '' : 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 === 0 ? '' : 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>
|
|
|
|
{/* ── Sección: Automatización ──────────────────────────────────── */}
|
|
<div>
|
|
<Separator className="mb-4" />
|
|
|
|
<div className="space-y-3">
|
|
<p className="text-xs font-semibold text-slate-500 uppercase tracking-wide">Automatización</p>
|
|
|
|
{/* Toggle */}
|
|
<div className="flex items-center justify-between">
|
|
<Label htmlFor="automatable-toggle" className="text-xs font-medium cursor-pointer">
|
|
¿Es automatizable?
|
|
</Label>
|
|
<Switch
|
|
id="automatable-toggle"
|
|
checked={localActivity.automatable}
|
|
onCheckedChange={(v) => handleChange('automatable', v)}
|
|
aria-label="Marcar actividad como automatizable"
|
|
/>
|
|
</div>
|
|
|
|
{/* Campos — aparecen con transición suave cuando toggle=ON */}
|
|
<div className={`space-y-3 overflow-hidden transition-all duration-200 ${localActivity.automatable ? 'max-h-80 opacity-100' : 'max-h-0 opacity-0'}`}>
|
|
|
|
{/* Costo automatizado */}
|
|
<div className="space-y-1.5">
|
|
<div className="flex items-center gap-1.5">
|
|
<Label htmlFor="automated-cost" className="text-xs font-medium">
|
|
Costo automatizado
|
|
</Label>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Info className="h-3 w-3 text-slate-400 cursor-help" />
|
|
</TooltipTrigger>
|
|
<TooltipContent side="right" className="max-w-56 text-xs">
|
|
Costo recurrente por ejecución automatizada. No incluye la inversión inicial del proyecto.
|
|
Ver METHODOLOGY_AUTOMATED_COST.md para la guía de cálculo.
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
</div>
|
|
<Input
|
|
id="automated-cost"
|
|
type="number"
|
|
min={0}
|
|
step={1}
|
|
value={localActivity.automatedCostFixed === 0 ? '' : localActivity.automatedCostFixed}
|
|
onChange={(e) => handleChange('automatedCostFixed', Math.max(0, parseFloat(e.target.value) || 0))}
|
|
className="font-mono h-8 text-sm"
|
|
placeholder="0"
|
|
/>
|
|
</div>
|
|
|
|
{/* Tiempo automatizado */}
|
|
<div className="space-y-1.5">
|
|
<Label htmlFor="automated-time" className="text-xs font-medium">
|
|
Tiempo automatizado (min)
|
|
</Label>
|
|
<Input
|
|
id="automated-time"
|
|
type="number"
|
|
min={0}
|
|
step={1}
|
|
value={localActivity.automatedTimeMinutes === 0 ? '' : localActivity.automatedTimeMinutes}
|
|
onChange={(e) => handleChange('automatedTimeMinutes', Math.max(0, parseFloat(e.target.value) || 0))}
|
|
className="font-mono h-8 text-sm"
|
|
placeholder="0"
|
|
/>
|
|
</div>
|
|
|
|
{/* Helper ámbar — siempre en el DOM para que la transición funcione
|
|
al tipear un valor (no desaparece instantáneo sino con fade).
|
|
La condición controla opacity + max-h, no mount/unmount. */}
|
|
{(() => {
|
|
const showHelper = localActivity.automatable
|
|
&& localActivity.automatedCostFixed === 0
|
|
&& localActivity.automatedTimeMinutes === 0
|
|
return (
|
|
<div
|
|
aria-hidden={!showHelper}
|
|
className={`overflow-hidden transition-all duration-150 ease-out ${
|
|
showHelper ? 'opacity-100 max-h-20' : 'opacity-0 max-h-0'
|
|
}`}
|
|
>
|
|
<div className="flex items-start gap-2 rounded-lg bg-amber-50 border border-amber-200 px-3 py-2">
|
|
<AlertTriangle className="h-3.5 w-3.5 text-amber-600 shrink-0 mt-0.5" />
|
|
<p className="text-xs text-amber-700 leading-snug">
|
|
Esta actividad aparecerá en "Transparencia metodológica" del reporte hasta que cargues valores.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
)
|
|
})()}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Guardar */}
|
|
<Button
|
|
size="sm"
|
|
onClick={handleSave}
|
|
disabled={!isDirty}
|
|
className="w-full"
|
|
>
|
|
{isDirty ? 'Guardar cambios' : 'Sin cambios'}
|
|
</Button>
|
|
</div>
|
|
</ScrollArea>
|
|
)
|
|
}
|