Antes de la última revisión de etapa 4. Sprint 1. Previo al ajuste de look n feel de app, reportes, nombre y colores.
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { PlusCircle, Trash2, Info } from 'lucide-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'
|
||||
@@ -7,6 +7,8 @@ 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'
|
||||
@@ -226,6 +228,101 @@ export function ActivityPanel({ selectedElementId }: ActivityPanelProps) {
|
||||
)}
|
||||
</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"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useEffect, useRef, useCallback } from 'react'
|
||||
import BpmnViewer from 'bpmn-js/lib/Viewer'
|
||||
|
||||
export type BpmnElementCategory = 'activity' | 'gateway' | 'other'
|
||||
@@ -19,37 +19,117 @@ function classifyElement(type: string): BpmnElementCategory {
|
||||
return 'other'
|
||||
}
|
||||
|
||||
// SVG inline del ícono Bot de Lucide (12×12 px, color ámbar #f59e0b).
|
||||
// Copiado de lucide.dev/icons/bot — robot con antena, cuerpo rectangular y ojos.
|
||||
const BOT_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="#f59e0b" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 8V4H8"/>
|
||||
<rect width="16" height="12" x="4" y="8" rx="2"/>
|
||||
<path d="M2 14h2"/>
|
||||
<path d="M20 14h2"/>
|
||||
<path d="M15 13v2"/>
|
||||
<path d="M9 13v2"/>
|
||||
</svg>`
|
||||
|
||||
// HTML del badge de automatización.
|
||||
// Fondo #fffbeb (amber-50): identidad cromática con el ícono Bot ámbar,
|
||||
// contrasta con el fondo blanco de los nodos BPMN.
|
||||
function makeBadgeHtml(): string {
|
||||
return `<div title="Actividad marcada como automatizable" style="
|
||||
width:20px;height:20px;
|
||||
border-radius:50%;
|
||||
background:#fffbeb;
|
||||
border:1px solid #e2e8f0;
|
||||
box-shadow:0 1px 3px rgba(0,0,0,0.12);
|
||||
display:flex;align-items:center;justify-content:center;
|
||||
cursor:default;
|
||||
pointer-events:none;
|
||||
">${BOT_SVG}</div>`
|
||||
}
|
||||
|
||||
// Identificador del tipo de overlay para poder remover solo los nuestros.
|
||||
const OVERLAY_TYPE = 'automation-indicator'
|
||||
|
||||
interface BpmnCanvasProps {
|
||||
xml: string
|
||||
onElementClick?: (elementId: string, elementName: string, category: BpmnElementCategory) => void
|
||||
selectedElementId?: string | null
|
||||
automatableElementIds?: string[] // IDs de actividades marcadas como automatizables
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function BpmnCanvas({ xml, onElementClick, selectedElementId, className }: BpmnCanvasProps) {
|
||||
export function BpmnCanvas({
|
||||
xml,
|
||||
onElementClick,
|
||||
selectedElementId,
|
||||
automatableElementIds = [],
|
||||
className,
|
||||
}: BpmnCanvasProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const viewerRef = useRef<InstanceType<typeof BpmnViewer> | null>(null)
|
||||
const isImportedRef = useRef(false)
|
||||
|
||||
// ─── Inicializar viewer ────────────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return
|
||||
const viewer = new BpmnViewer({ container: containerRef.current })
|
||||
viewerRef.current = viewer
|
||||
return () => {
|
||||
isImportedRef.current = false
|
||||
viewer.destroy()
|
||||
viewerRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
// ─── Sincronizar overlays de automatización ───────────────────────────────
|
||||
const syncAutomationOverlays = useCallback(() => {
|
||||
const viewer = viewerRef.current
|
||||
if (!viewer || !isImportedRef.current) return
|
||||
|
||||
const overlays = viewer.get('overlays') as any
|
||||
const reg = viewer.get('elementRegistry') as any
|
||||
|
||||
// Remover overlays anteriores del tipo propio antes de re-agregar
|
||||
overlays.remove({ type: OVERLAY_TYPE })
|
||||
|
||||
for (const elementId of automatableElementIds) {
|
||||
try {
|
||||
// Leer el ancho real del elemento para posicionar el badge en la
|
||||
// esquina superior derecha con overlap: left = width - 12 pone el
|
||||
// borde derecho del badge 8px fuera del borde derecho del nodo.
|
||||
// (right: -8 en la API tiene semántica distinta: left = -right + width
|
||||
// con -8 daría left = 8 + width, enteramente fuera del nodo.)
|
||||
const el = reg?.get(elementId)
|
||||
const width = el?.width ?? 100
|
||||
overlays.add(elementId, OVERLAY_TYPE, {
|
||||
position: { top: -8, left: width - 12 },
|
||||
html: makeBadgeHtml(),
|
||||
})
|
||||
} catch {
|
||||
// El elemento puede no existir en el diagrama actual — ignorar
|
||||
}
|
||||
}
|
||||
}, [automatableElementIds])
|
||||
|
||||
// ─── Importar XML ──────────────────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
const viewer = viewerRef.current
|
||||
if (!viewer || !xml) return
|
||||
isImportedRef.current = false
|
||||
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')
|
||||
isImportedRef.current = true
|
||||
syncAutomationOverlays()
|
||||
}).catch((err: Error) => console.error('Error cargando BPMN:', err))
|
||||
}, [xml])
|
||||
}, [xml, syncAutomationOverlays])
|
||||
|
||||
// ─── Re-sincronizar overlays cuando cambia la lista de automatizables ─────
|
||||
useEffect(() => {
|
||||
syncAutomationOverlays()
|
||||
}, [syncAutomationOverlays])
|
||||
|
||||
// ─── Click handler ─────────────────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
const viewer = viewerRef.current
|
||||
if (!viewer || !onElementClick) return
|
||||
@@ -67,6 +147,7 @@ export function BpmnCanvas({ xml, onElementClick, selectedElementId, className }
|
||||
return () => eventBus.off('element.click', handler)
|
||||
}, [onElementClick])
|
||||
|
||||
// ─── Highlight del elemento seleccionado ──────────────────────────────────
|
||||
useEffect(() => {
|
||||
const viewer = viewerRef.current
|
||||
if (!viewer) return
|
||||
|
||||
@@ -6,6 +6,7 @@ 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 { Separator } from '@/components/ui/separator'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { useProcessStore } from '@/store/process-store'
|
||||
import { formatPercent } from '@/lib/format'
|
||||
@@ -27,6 +28,9 @@ export function GlobalSettingsPanel() {
|
||||
const [localClient, setLocalClient] = useState('')
|
||||
const [localCurrency, setLocalCurrency] = useState('USD')
|
||||
const [localOverhead, setLocalOverhead] = useState(20)
|
||||
const [localFrequency, setLocalFrequency] = useState(1000)
|
||||
const [localHorizon, setLocalHorizon] = useState(3)
|
||||
const [localInvestment, setLocalInvestment] = useState(0)
|
||||
const [isDirty, setIsDirty] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -35,18 +39,26 @@ export function GlobalSettingsPanel() {
|
||||
setLocalClient(currentProcess.clientName)
|
||||
setLocalCurrency(currentProcess.currency)
|
||||
setLocalOverhead(Math.round(currentProcess.overheadPercentage * 100))
|
||||
setLocalFrequency(currentProcess.annualFrequency)
|
||||
setLocalHorizon(currentProcess.analysisHorizonYears)
|
||||
setLocalInvestment(currentProcess.automationInvestment)
|
||||
setIsDirty(false)
|
||||
}, [currentProcess])
|
||||
|
||||
function markDirty() { setIsDirty(true) }
|
||||
|
||||
async function handleSave() {
|
||||
const clampedHorizon = Math.min(10, Math.max(1, localHorizon))
|
||||
await updateProcess({
|
||||
name: localName.trim() || 'Proceso sin nombre',
|
||||
clientName: localClient.trim(),
|
||||
currency: localCurrency,
|
||||
overheadPercentage: localOverhead / 100,
|
||||
annualFrequency: Math.max(1, localFrequency),
|
||||
analysisHorizonYears: clampedHorizon,
|
||||
automationInvestment: Math.max(0, localInvestment),
|
||||
})
|
||||
setLocalHorizon(clampedHorizon)
|
||||
setIsDirty(false)
|
||||
}
|
||||
|
||||
@@ -125,6 +137,99 @@ export function GlobalSettingsPanel() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* ── Sección: Análisis de Impacto ──────────────────────────────── */}
|
||||
<div>
|
||||
<Separator className="mb-4" />
|
||||
<div className="space-y-4">
|
||||
<p className="text-xs font-semibold text-slate-500 uppercase tracking-wide">Análisis de Impacto</p>
|
||||
|
||||
{/* Frecuencia anual */}
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Label htmlFor="annual-frequency" className="text-xs font-medium">
|
||||
Frecuencia anual
|
||||
</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">
|
||||
Cantidad de veces que se ejecuta el proceso completo por año. Se usa para anualizar los costos y calcular el ahorro.
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<Input
|
||||
id="annual-frequency"
|
||||
type="number"
|
||||
min={1}
|
||||
max={10_000_000}
|
||||
step={1}
|
||||
value={localFrequency === 0 ? '' : localFrequency}
|
||||
onChange={(e) => { setLocalFrequency(Math.max(1, parseInt(e.target.value) || 1)); markDirty() }}
|
||||
className="font-mono h-8 text-sm"
|
||||
placeholder="1000"
|
||||
/>
|
||||
<p className="text-[10px] text-slate-400">
|
||||
Ejemplos: 12 (mensual) · 52 (semanal) · 250 (diario laboral) · 8760 (cada hora)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Horizonte de análisis */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Label className="text-xs font-medium">Horizonte de análisis</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">
|
||||
Años hasta los que se proyecta el ahorro acumulado. Determina el eje X del gráfico de ROI.
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<span className="text-sm font-mono font-semibold text-slate-700">
|
||||
{localHorizon} {localHorizon === 1 ? 'año' : 'años'}
|
||||
</span>
|
||||
</div>
|
||||
<Slider
|
||||
value={[localHorizon]}
|
||||
min={1}
|
||||
max={10}
|
||||
step={1}
|
||||
onValueChange={([v]) => { setLocalHorizon(v); markDirty() }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Inversión en automatización */}
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Label htmlFor="automation-investment" className="text-xs font-medium">
|
||||
Inversión en automatización
|
||||
</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 único total del proyecto: desarrollo, licencias, implementación, capacitación. No incluir costos recurrentes.
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<Input
|
||||
id="automation-investment"
|
||||
type="number"
|
||||
min={0}
|
||||
step={100}
|
||||
value={localInvestment === 0 ? '' : localInvestment}
|
||||
onChange={(e) => { setLocalInvestment(Math.max(0, parseFloat(e.target.value) || 0)); markDirty() }}
|
||||
className="font-mono h-8 text-sm"
|
||||
placeholder="0"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Guardar */}
|
||||
<Button
|
||||
size="sm"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef, useState, useCallback, useMemo } from 'react'
|
||||
import { useEffect, useRef, useState, useCallback, useMemo, useDeferredValue } from 'react'
|
||||
import { useParams, useNavigate } from '@tanstack/react-router'
|
||||
import {
|
||||
Loader2, BarChart3, Play, AlertTriangle,
|
||||
@@ -23,8 +23,8 @@ 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 { currentProcess, activities, isLoading, error, loadProcess, gateways } = useProcessStore()
|
||||
const { isRunning, resultActual, error: simError, selectActivity } = useSimulationStore()
|
||||
const { simulate } = useSimulate()
|
||||
const { toast } = useToast()
|
||||
|
||||
@@ -64,6 +64,14 @@ export function WorkspacePage() {
|
||||
|
||||
const canSimulate = !isRunning && xorValidation.valid
|
||||
|
||||
// IDs de actividades marcadas como automatizables — alimenta los overlays del canvas.
|
||||
// useDeferredValue evita re-renders en cascada mientras el usuario edita el panel.
|
||||
const automatableElementIds = useMemo(
|
||||
() => activities.filter((a) => a.automatable).map((a) => a.bpmnElementId),
|
||||
[activities]
|
||||
)
|
||||
const deferredAutomatableIds = useDeferredValue(automatableElementIds)
|
||||
|
||||
const handleElementClick = useCallback((
|
||||
elementId: string,
|
||||
elementName: string,
|
||||
@@ -91,6 +99,8 @@ export function WorkspacePage() {
|
||||
if (simResult) navigate({ to: '/report/$processId', params: { processId } })
|
||||
}
|
||||
|
||||
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
@@ -161,7 +171,7 @@ export function WorkspacePage() {
|
||||
)}
|
||||
|
||||
{/* Ver reporte si ya hay simulación */}
|
||||
{result && (
|
||||
{resultActual && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@@ -223,6 +233,7 @@ export function WorkspacePage() {
|
||||
xml={currentProcess.bpmnXml}
|
||||
onElementClick={handleElementClick}
|
||||
selectedElementId={selectedElementId}
|
||||
automatableElementIds={deferredAutomatableIds}
|
||||
className="absolute inset-0"
|
||||
/>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user