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:
12
src/features/report/AutomationScenarioNote.tsx
Normal file
12
src/features/report/AutomationScenarioNote.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
// Leyenda para el tab "Automatizado" del reporte.
|
||||
// Explica por qué ciertas actividades mantienen su costo original.
|
||||
// Se coloca al pie del heatmap en el tab "Automatizado" — wired en Etapa 3.
|
||||
|
||||
export function AutomationScenarioNote() {
|
||||
return (
|
||||
<p className="text-xs text-slate-500 text-center mt-2 px-4">
|
||||
💡 Las actividades no marcadas como automatizables mantienen su costo original en este
|
||||
escenario. Identificalas por el ícono ámbar de automatización en el workspace.
|
||||
</p>
|
||||
)
|
||||
}
|
||||
130
src/features/report/ComparisonTable.tsx
Normal file
130
src/features/report/ComparisonTable.tsx
Normal file
@@ -0,0 +1,130 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { Bot, ArrowUpDown } from 'lucide-react'
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
||||
import { formatCurrency, formatPercent } from '@/lib/format'
|
||||
import type { ActivitySimResult } from '@/domain/types'
|
||||
|
||||
interface ComparisonRow {
|
||||
activityId: string
|
||||
activityName: string
|
||||
actualCost: number
|
||||
automatedCost: number
|
||||
savings: number
|
||||
savingsPercent: number
|
||||
automatable: boolean
|
||||
}
|
||||
|
||||
interface ComparisonTableProps {
|
||||
perActivityActual: ActivitySimResult[]
|
||||
perActivityAutomated: ActivitySimResult[]
|
||||
automatableIds: Set<string> // bpmnElementIds marcados como automatizables
|
||||
currency: string
|
||||
}
|
||||
|
||||
export function ComparisonTable({
|
||||
perActivityActual,
|
||||
perActivityAutomated,
|
||||
automatableIds,
|
||||
currency,
|
||||
}: ComparisonTableProps) {
|
||||
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('desc')
|
||||
|
||||
const automatedByActivityId = useMemo(() => {
|
||||
const m = new Map<string, ActivitySimResult>()
|
||||
for (const a of perActivityAutomated) m.set(a.activityId, a)
|
||||
return m
|
||||
}, [perActivityAutomated])
|
||||
|
||||
const rows: ComparisonRow[] = useMemo(() => {
|
||||
return perActivityActual
|
||||
.map((act) => {
|
||||
const automated = automatedByActivityId.get(act.activityId)
|
||||
const automatedCost = automated?.expectedTotalCost ?? act.expectedTotalCost
|
||||
const savings = act.expectedTotalCost - automatedCost
|
||||
const savingsPercent =
|
||||
act.expectedTotalCost > 0 ? (savings / act.expectedTotalCost) * 100 : 0
|
||||
return {
|
||||
activityId: act.activityId,
|
||||
activityName: act.activityName,
|
||||
actualCost: act.expectedTotalCost,
|
||||
automatedCost,
|
||||
savings,
|
||||
savingsPercent,
|
||||
automatable: automatableIds.has(act.bpmnElementId),
|
||||
}
|
||||
})
|
||||
.sort((a, b) => (b.savings - a.savings) * (sortDir === 'desc' ? 1 : -1))
|
||||
}, [perActivityActual, automatedByActivityId, automatableIds, sortDir])
|
||||
|
||||
if (rows.length === 0) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-24 text-xs text-slate-400 rounded-xl border border-slate-200">
|
||||
Sin actividades para comparar
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-slate-200 overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader className="bg-slate-50">
|
||||
<tr>
|
||||
<TableHead className="w-8 text-xs" />
|
||||
<TableHead className="text-xs">Actividad</TableHead>
|
||||
<TableHead className="text-xs text-right">Costo actual</TableHead>
|
||||
<TableHead className="text-xs text-right">Costo automatizado</TableHead>
|
||||
<TableHead
|
||||
className="text-xs text-right cursor-pointer select-none hover:text-foreground"
|
||||
onClick={() => setSortDir((d) => (d === 'desc' ? 'asc' : 'desc'))}
|
||||
>
|
||||
<span className="inline-flex items-center justify-end gap-1">
|
||||
Ahorro
|
||||
<ArrowUpDown className="h-3 w-3 opacity-40" />
|
||||
</span>
|
||||
</TableHead>
|
||||
<TableHead className="text-xs text-right">% ahorro</TableHead>
|
||||
</tr>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{rows.map((row) => {
|
||||
const savingsColor =
|
||||
row.savings > 0
|
||||
? 'text-emerald-700'
|
||||
: row.savings < 0
|
||||
? 'text-red-600'
|
||||
: 'text-slate-400'
|
||||
return (
|
||||
<TableRow key={row.activityId}>
|
||||
<TableCell className="py-2">
|
||||
{row.automatable && (
|
||||
<Bot
|
||||
className="h-3.5 w-3.5 text-amber-500"
|
||||
aria-label="Marcada como automatizable"
|
||||
/>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs font-medium text-slate-800 max-w-48 truncate">
|
||||
{row.activityName}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs font-mono text-slate-600 text-right">
|
||||
{formatCurrency(row.actualCost, currency)}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs font-mono text-slate-600 text-right">
|
||||
{formatCurrency(row.automatedCost, currency)}
|
||||
</TableCell>
|
||||
<TableCell className={`text-xs font-mono font-semibold text-right ${savingsColor}`}>
|
||||
{row.savings > 0 ? '+' : ''}
|
||||
{formatCurrency(row.savings, currency)}
|
||||
</TableCell>
|
||||
<TableCell className={`text-xs font-mono text-right ${savingsColor}`}>
|
||||
{row.savings > 0 ? '+' : ''}
|
||||
{formatPercent(row.savingsPercent)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
115
src/features/report/CumulativeSavingsChart.tsx
Normal file
115
src/features/report/CumulativeSavingsChart.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
import { useMemo } from 'react'
|
||||
import ReactECharts from 'echarts-for-react'
|
||||
import { formatCurrency } from '@/lib/format'
|
||||
import type { RoiResult } from '@/domain/roi'
|
||||
|
||||
interface CumulativeSavingsChartProps {
|
||||
roi: RoiResult
|
||||
horizonYears: number
|
||||
currency: string
|
||||
investment: number
|
||||
}
|
||||
|
||||
export function CumulativeSavingsChart({
|
||||
roi,
|
||||
horizonYears,
|
||||
currency,
|
||||
investment,
|
||||
}: CumulativeSavingsChartProps) {
|
||||
const option = useMemo(() => {
|
||||
const years = Array.from({ length: horizonYears + 1 }, (_, i) => i)
|
||||
const values = years.map((y) => roi.annualSavings * y - investment)
|
||||
|
||||
const markLineData: unknown[] = []
|
||||
if (isFinite(roi.paybackYears) && roi.paybackYears > 0 && roi.paybackYears <= horizonYears) {
|
||||
markLineData.push({
|
||||
xAxis: roi.paybackYears,
|
||||
name: 'Payback',
|
||||
label: {
|
||||
formatter: `Payback\n${roi.paybackYears.toFixed(1)} años`,
|
||||
position: 'insideEndTop',
|
||||
fontSize: 10,
|
||||
color: '#10b981',
|
||||
},
|
||||
lineStyle: { color: '#10b981', type: 'dashed', width: 2 },
|
||||
})
|
||||
}
|
||||
|
||||
// Línea de referencia en y=0 para identificar el break-even visualmente
|
||||
markLineData.push({
|
||||
yAxis: 0,
|
||||
name: 'Break-even',
|
||||
label: { formatter: '', position: 'insideStartTop' },
|
||||
lineStyle: { color: '#94a3b8', type: 'solid', width: 1 },
|
||||
})
|
||||
|
||||
return {
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
formatter: (params: unknown[]) => {
|
||||
const p = params[0] as { dataIndex: number; value: [number, number] }
|
||||
const year = p.value[0]
|
||||
const val = p.value[1]
|
||||
return `<div style="font-size:12px;line-height:1.6">
|
||||
<div style="font-weight:600;margin-bottom:4px">Año ${year}</div>
|
||||
<div>Acumulado neto: <strong style="color:${val >= 0 ? '#10b981' : '#ef4444'}">${formatCurrency(val, currency)}</strong></div>
|
||||
</div>`
|
||||
},
|
||||
},
|
||||
grid: { left: 16, right: 24, top: 16, bottom: 8, containLabel: true },
|
||||
xAxis: {
|
||||
type: 'value',
|
||||
name: 'Año',
|
||||
nameLocation: 'end',
|
||||
nameTextStyle: { fontSize: 10, color: '#94a3b8' },
|
||||
min: 0,
|
||||
max: horizonYears,
|
||||
interval: 1,
|
||||
axisLabel: { fontSize: 11, formatter: (v: number) => `Año ${v}` },
|
||||
splitLine: { lineStyle: { color: '#f1f5f9' } },
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
axisLabel: {
|
||||
fontSize: 11,
|
||||
formatter: (v: number) => {
|
||||
if (v === 0) return '0'
|
||||
const abs = Math.abs(v)
|
||||
const sign = v < 0 ? '-' : ''
|
||||
if (abs >= 1_000_000) return `${sign}${(abs / 1_000_000).toFixed(1)}M`
|
||||
if (abs >= 1_000) return `${sign}${(abs / 1_000).toFixed(0)}k`
|
||||
return String(v)
|
||||
},
|
||||
},
|
||||
splitLine: { lineStyle: { color: '#f1f5f9' } },
|
||||
},
|
||||
series: [
|
||||
{
|
||||
type: 'line',
|
||||
data: years.map((y, i) => [y, values[i]]),
|
||||
smooth: false,
|
||||
lineStyle: { color: '#3b82f6', width: 2.5 },
|
||||
areaStyle: {
|
||||
color: {
|
||||
type: 'linear',
|
||||
x: 0, y: 0, x2: 0, y2: 1,
|
||||
colorStops: [
|
||||
{ offset: 0, color: 'rgba(59,130,246,0.18)' },
|
||||
{ offset: 1, color: 'rgba(59,130,246,0.02)' },
|
||||
],
|
||||
},
|
||||
},
|
||||
itemStyle: { color: '#3b82f6' },
|
||||
symbolSize: 6,
|
||||
markLine: {
|
||||
silent: true,
|
||||
symbol: ['none', 'none'],
|
||||
data: markLineData,
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
}, [roi, horizonYears, currency, investment])
|
||||
|
||||
return <ReactECharts option={option} style={{ height: 280 }} notMerge />
|
||||
}
|
||||
@@ -1,14 +1,17 @@
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
import { useCallback, useMemo, useRef, useState } from 'react'
|
||||
import { useParams, useNavigate } from '@tanstack/react-router'
|
||||
import { AlertTriangle, Home } from 'lucide-react'
|
||||
import { AlertTriangle, Home, Info } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { TooltipProvider } from '@/components/ui/tooltip'
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
|
||||
import { TooltipProvider, Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { AppFooter } from '@/components/AppFooter'
|
||||
import { calculateRoi } from '@/domain/roi'
|
||||
import { useReportData } from './useReportData'
|
||||
import { ReportHeader } from './ReportHeader'
|
||||
import { KpiBar } from './KpiBar'
|
||||
import { RoiKpiBar } from './RoiKpiBar'
|
||||
import { HeatmapCanvas, type HeatmapCanvasHandle } from './HeatmapCanvas'
|
||||
import { HeatmapLegend } from './HeatmapLegend'
|
||||
import { HeatmapModeToggle } from './HeatmapModeToggle'
|
||||
@@ -17,48 +20,102 @@ import { TopActivitiesChart } from './TopActivitiesChart'
|
||||
import { CostCompositionChart } from './CostCompositionChart'
|
||||
import { CostByResourceChart } from './CostByResourceChart'
|
||||
import { ActivitiesTable } from './ActivitiesTable'
|
||||
import { ComparisonTable } from './ComparisonTable'
|
||||
import { CumulativeSavingsChart } from './CumulativeSavingsChart'
|
||||
import { TopSavingsChart } from './TopSavingsChart'
|
||||
import { TransparencySection } from './TransparencySection'
|
||||
import { AutomationScenarioNote } from './AutomationScenarioNote'
|
||||
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 { process, simulation, activities, 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)
|
||||
|
||||
// Set de bpmnElementIds marcados como automatizables — debe estar antes de los early returns
|
||||
const automatableIds = useMemo(
|
||||
() => new Set(activities.filter((a) => a.automatable).map((a) => a.bpmnElementId)),
|
||||
[activities]
|
||||
)
|
||||
|
||||
// ROI — calculado solo cuando hay ambos resultados
|
||||
const roi = useMemo(() => {
|
||||
const result = simulation?.result
|
||||
const resultAutomated = simulation?.resultAutomated
|
||||
if (!result || !resultAutomated || !process) return null
|
||||
return calculateRoi({
|
||||
costPerExecutionActual: result.totalCost,
|
||||
costPerExecutionAutomated: resultAutomated.totalCost,
|
||||
annualFrequency: process.annualFrequency,
|
||||
analysisHorizonYears: process.analysisHorizonYears,
|
||||
automationInvestment: process.automationInvestment,
|
||||
})
|
||||
}, [simulation, process])
|
||||
|
||||
// Estado y refs para el tab "Actual"
|
||||
const [selectedIdActual, setSelectedIdActual] = useState<string | null>(null)
|
||||
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)
|
||||
// Estado y refs para el tab "Automatizado"
|
||||
const [selectedIdAuto, setSelectedIdAuto] = useState<string | null>(null)
|
||||
const heatmapRefAuto = useRef<HeatmapCanvasHandle>(null)
|
||||
const heatmapSectionRefAuto = useRef<HTMLDivElement>(null)
|
||||
const tableSectionRefAuto = useRef<HTMLDivElement>(null)
|
||||
|
||||
const [isExportingPdf, setIsExportingPdf] = useState(false)
|
||||
const [isExportingCsv, setIsExportingCsv] = useState(false)
|
||||
const [activeTab, setActiveTab] = useState<'actual' | 'automatizado' | 'roi'>('actual')
|
||||
|
||||
// Tab "Actual" — cross-linking heatmap ↔ tabla
|
||||
const handleHeatmapClickActual = useCallback((bpmnElementId: string) => {
|
||||
setSelectedIdActual(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)
|
||||
const handleTableRowClickActual = useCallback((bpmnElementId: string) => {
|
||||
setSelectedIdActual(bpmnElementId)
|
||||
heatmapRef.current?.highlightElement(bpmnElementId)
|
||||
setTimeout(() => {
|
||||
heatmapSectionRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||
}, 50)
|
||||
}, [])
|
||||
|
||||
// Tab "Automatizado" — cross-linking heatmap ↔ tabla
|
||||
const handleHeatmapClickAuto = useCallback((bpmnElementId: string) => {
|
||||
setSelectedIdAuto(bpmnElementId)
|
||||
setTimeout(() => {
|
||||
tableSectionRefAuto.current?.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||
}, 50)
|
||||
}, [])
|
||||
|
||||
const handleTableRowClickAuto = useCallback((bpmnElementId: string) => {
|
||||
setSelectedIdAuto(bpmnElementId)
|
||||
heatmapRefAuto.current?.highlightElement(bpmnElementId)
|
||||
setTimeout(() => {
|
||||
heatmapSectionRefAuto.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
|
||||
let heatmapImageData: string | null = null
|
||||
if (activeTab === 'actual') {
|
||||
heatmapImageData = await heatmapRef.current?.captureImage() ?? null
|
||||
} else if (activeTab === 'automatizado') {
|
||||
heatmapImageData = await heatmapRefAuto.current?.captureImage() ?? null
|
||||
}
|
||||
const { exportToPdf } = await import('@/lib/export/pdf-export')
|
||||
await exportToPdf({ process, simulation, resources, heatmapImageData })
|
||||
await exportToPdf({ process, simulation, resources, heatmapImageData, tab: activeTab, activities })
|
||||
toast({ title: 'PDF generado', description: 'El reporte se descargó correctamente.' })
|
||||
} catch (err) {
|
||||
console.error('PDF export error:', err)
|
||||
@@ -66,14 +123,14 @@ export function ReportPage() {
|
||||
} finally {
|
||||
setIsExportingPdf(false)
|
||||
}
|
||||
}, [process, simulation, resources, isExportingPdf, toast])
|
||||
}, [process, simulation, resources, isExportingPdf, toast, activeTab, activities])
|
||||
|
||||
const handleExportCsv = useCallback(async () => {
|
||||
if (!process || !simulation || isExportingCsv) return
|
||||
setIsExportingCsv(true)
|
||||
try {
|
||||
const { exportToCsv } = await import('@/lib/export/csv-export')
|
||||
exportToCsv({ process, simulation, resources })
|
||||
exportToCsv({ process, simulation, resources, tab: activeTab, activities })
|
||||
toast({ title: 'CSV generado', description: 'Los datos se descargaron correctamente.' })
|
||||
} catch (err) {
|
||||
console.error('CSV export error:', err)
|
||||
@@ -81,14 +138,13 @@ export function ReportPage() {
|
||||
} finally {
|
||||
setIsExportingCsv(false)
|
||||
}
|
||||
}, [process, simulation, resources, isExportingCsv, toast])
|
||||
}, [process, simulation, resources, isExportingCsv, toast, activeTab, activities])
|
||||
|
||||
// ── Loading — skeleton en lugar de pantalla en blanco ────────────────────
|
||||
// ── Loading ───────────────────────────────────────────────────────────────
|
||||
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" />
|
||||
@@ -100,7 +156,6 @@ export function ReportPage() {
|
||||
<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">
|
||||
@@ -113,12 +168,10 @@ export function ReportPage() {
|
||||
</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) => (
|
||||
@@ -159,14 +212,19 @@ export function ReportPage() {
|
||||
}
|
||||
|
||||
const result = simulation.result
|
||||
const perActivity = result.perActivity
|
||||
const resultAutomated = simulation.resultAutomated ?? null
|
||||
const hasAutomatedResult = resultAutomated !== null
|
||||
|
||||
// Tabs de escenarios se habilitan solo cuando hay resultado automatizado Y hay actividades
|
||||
// marcadas como automatizables. Sin automatizables, los tabs muestran datos idénticos al actual.
|
||||
const hasAutomatedScenario = hasAutomatedResult && automatableIds.size > 0
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<div className="min-h-screen bg-slate-50">
|
||||
<div className="max-w-screen-xl mx-auto px-6 py-10">
|
||||
|
||||
{/* ── 1. Header ─────────────────────────────────────────────── */}
|
||||
{/* ── Header (fuera de los tabs: aplica a todos los escenarios) ──── */}
|
||||
<ReportHeader
|
||||
process={process}
|
||||
simulatedAt={simulation.executedAt}
|
||||
@@ -177,102 +235,328 @@ export function ReportPage() {
|
||||
isExportingCsv={isExportingCsv}
|
||||
/>
|
||||
|
||||
{/* ── 2. KPI Bar ────────────────────────────────────────────── */}
|
||||
<KpiBar result={result} currency={process.currency} />
|
||||
{/* ── Tabs ─────────────────────────────────────────────────────── */}
|
||||
<Tabs defaultValue="actual" className="w-full" onValueChange={(v) => setActiveTab(v as typeof activeTab)}>
|
||||
<TabsList className="mb-6">
|
||||
<TabsTrigger value="actual">Actual</TabsTrigger>
|
||||
|
||||
{/* ── 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>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="contents">
|
||||
<TabsTrigger value="automatizado" disabled={!hasAutomatedScenario}>
|
||||
Automatizado
|
||||
</TabsTrigger>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
{!hasAutomatedScenario && (
|
||||
<TooltipContent side="bottom" className="text-xs max-w-56">
|
||||
{!hasAutomatedResult
|
||||
? 'Re-simulá el proceso para generar el escenario automatizado'
|
||||
: 'Marcá al menos una actividad como automatizable en el workspace para ver este escenario'}
|
||||
</TooltipContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
|
||||
<HeatmapCanvas
|
||||
ref={heatmapRef}
|
||||
xml={process.bpmnXml}
|
||||
perActivity={perActivity}
|
||||
mode={heatmapMode}
|
||||
currency={process.currency}
|
||||
selectedId={selectedId}
|
||||
onActivityClick={handleHeatmapClick}
|
||||
minHeight={520}
|
||||
/>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="contents">
|
||||
<TabsTrigger value="roi" disabled={!hasAutomatedScenario}>
|
||||
Comparación + ROI
|
||||
</TabsTrigger>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
{!hasAutomatedScenario && (
|
||||
<TooltipContent side="bottom" className="text-xs max-w-56">
|
||||
{!hasAutomatedResult
|
||||
? 'Re-simulá el proceso para generar el análisis de ROI'
|
||||
: 'Marcá al menos una actividad como automatizable en el workspace para calcular el ROI'}
|
||||
</TooltipContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
</TabsList>
|
||||
|
||||
<HeatmapLegend
|
||||
perActivity={perActivity}
|
||||
mode={heatmapMode}
|
||||
currency={process.currency}
|
||||
/>
|
||||
</div>
|
||||
{/* ──────────────── TAB: ACTUAL ──────────────────────────────── */}
|
||||
<TabsContent value="actual">
|
||||
<KpiBar result={result} currency={process.currency} />
|
||||
|
||||
{/* ── 4. Warnings ───────────────────────────────────────────── */}
|
||||
<WarningsBanner warnings={result.warnings} />
|
||||
<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>
|
||||
|
||||
{/* ── 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}
|
||||
<HeatmapCanvas
|
||||
ref={heatmapRef}
|
||||
xml={process.bpmnXml}
|
||||
perActivity={result.perActivity}
|
||||
mode={heatmapMode}
|
||||
currency={process.currency}
|
||||
selectedId={selectedIdActual}
|
||||
onActivityClick={handleHeatmapClickActual}
|
||||
minHeight={520}
|
||||
/>
|
||||
|
||||
<HeatmapLegend
|
||||
perActivity={result.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} />
|
||||
<WarningsBanner warnings={result.warnings} />
|
||||
|
||||
<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={result.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={result.perActivity}
|
||||
resources={resources}
|
||||
currency={process.currency}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</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}
|
||||
<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">
|
||||
({result.perActivity.length} actividades)
|
||||
</span>
|
||||
</h2>
|
||||
</div>
|
||||
<ActivitiesTable
|
||||
perActivity={result.perActivity}
|
||||
mode={heatmapMode}
|
||||
currency={process.currency}
|
||||
highlightedId={selectedIdActual}
|
||||
onRowClick={handleTableRowClickActual}
|
||||
/>
|
||||
</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" />
|
||||
<MethodologyFooter process={process} />
|
||||
<AppFooter />
|
||||
</TabsContent>
|
||||
|
||||
<Separator className="my-4" />
|
||||
{/* ──────────────── TAB: AUTOMATIZADO ───────────────────────── */}
|
||||
<TabsContent value="automatizado">
|
||||
{!hasAutomatedResult ? (
|
||||
<NeedsResimulate processId={processId} />
|
||||
) : (
|
||||
<>
|
||||
<KpiBar result={resultAutomated!} currency={process.currency} />
|
||||
|
||||
{/* ── 7. Footer metodológico ────────────────────────────────── */}
|
||||
<MethodologyFooter process={process} />
|
||||
<div ref={heatmapSectionRefAuto} 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 — Escenario Automatizado</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={heatmapRefAuto}
|
||||
xml={process.bpmnXml}
|
||||
perActivity={resultAutomated!.perActivity}
|
||||
mode={heatmapMode}
|
||||
currency={process.currency}
|
||||
selectedId={selectedIdAuto}
|
||||
onActivityClick={handleHeatmapClickAuto}
|
||||
minHeight={520}
|
||||
/>
|
||||
|
||||
<HeatmapLegend
|
||||
perActivity={resultAutomated!.perActivity}
|
||||
mode={heatmapMode}
|
||||
currency={process.currency}
|
||||
/>
|
||||
|
||||
<AutomationScenarioNote />
|
||||
</div>
|
||||
|
||||
<WarningsBanner warnings={resultAutomated!.warnings} />
|
||||
|
||||
<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={resultAutomated!.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={resultAutomated!} 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={resultAutomated!.perActivity}
|
||||
resources={resources}
|
||||
currency={process.currency}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div ref={tableSectionRefAuto} className="mb-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-base font-semibold text-slate-800">
|
||||
Detalle por actividad — Automatizado
|
||||
<span className="ml-2 text-sm font-normal text-slate-400">
|
||||
({resultAutomated!.perActivity.length} actividades)
|
||||
</span>
|
||||
</h2>
|
||||
</div>
|
||||
<ActivitiesTable
|
||||
perActivity={resultAutomated!.perActivity}
|
||||
mode={heatmapMode}
|
||||
currency={process.currency}
|
||||
highlightedId={selectedIdAuto}
|
||||
onRowClick={handleTableRowClickAuto}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Separator className="my-4" />
|
||||
<MethodologyFooter process={process} />
|
||||
<AppFooter />
|
||||
</>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* ──────────────── TAB: COMPARACIÓN + ROI ──────────────────── */}
|
||||
<TabsContent value="roi">
|
||||
{!hasAutomatedResult || !roi ? (
|
||||
<NeedsResimulate processId={processId} />
|
||||
) : (
|
||||
<>
|
||||
{/* KPIs de ROI */}
|
||||
<RoiKpiBar
|
||||
roi={roi}
|
||||
currency={process.currency}
|
||||
horizonYears={process.analysisHorizonYears}
|
||||
/>
|
||||
|
||||
{/* Tabla de comparación actividad por actividad */}
|
||||
<div className="mb-6">
|
||||
<h2 className="text-base font-semibold text-slate-800 mb-4">
|
||||
Comparación por actividad
|
||||
</h2>
|
||||
<ComparisonTable
|
||||
perActivityActual={result.perActivity}
|
||||
perActivityAutomated={resultAutomated!.perActivity}
|
||||
automatableIds={automatableIds}
|
||||
currency={process.currency}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Gráficos: ahorro acumulado + top ahorros */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 mb-6">
|
||||
<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">
|
||||
Ahorro acumulado neto ({process.analysisHorizonYears} años)
|
||||
</h3>
|
||||
<CumulativeSavingsChart
|
||||
roi={roi}
|
||||
horizonYears={process.analysisHorizonYears}
|
||||
currency={process.currency}
|
||||
investment={process.automationInvestment}
|
||||
/>
|
||||
</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">
|
||||
Top actividades por ahorro
|
||||
</h3>
|
||||
<TopSavingsChart
|
||||
perActivityActual={result.perActivity}
|
||||
perActivityAutomated={resultAutomated!.perActivity}
|
||||
currency={process.currency}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Transparencia metodológica (condicional) */}
|
||||
<div className="mb-6">
|
||||
<TransparencySection
|
||||
activities={activities}
|
||||
investment={process.automationInvestment}
|
||||
roi={roi ?? undefined}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Separator className="my-4" />
|
||||
<MethodologyFooter process={process} />
|
||||
<AppFooter />
|
||||
</>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<AppFooter />
|
||||
</div>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Componente auxiliar: aviso cuando falta re-simular ───────────────────────
|
||||
|
||||
function NeedsResimulate({ processId }: { processId: string }) {
|
||||
const navigate = useNavigate()
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center gap-4 py-20 text-center">
|
||||
<div className="w-12 h-12 bg-amber-50 rounded-full flex items-center justify-center">
|
||||
<Info className="h-6 w-6 text-amber-500" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-700 mb-1">
|
||||
Esta simulación no incluye el escenario automatizado
|
||||
</p>
|
||||
<p className="text-xs text-slate-400 max-w-sm">
|
||||
Volvé al workspace y presioná "Simular" para generar ambos escenarios.
|
||||
</p>
|
||||
</div>
|
||||
<Button size="sm" onClick={() => navigate({ to: '/workspace/$processId', params: { processId } })}>
|
||||
Ir al workspace
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
110
src/features/report/RoiKpiBar.tsx
Normal file
110
src/features/report/RoiKpiBar.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
import { TrendingDown, Calendar, BarChart3, DollarSign, Percent } from 'lucide-react'
|
||||
import { formatCurrency, formatPercent } from '@/lib/format'
|
||||
import type { RoiResult } from '@/domain/roi'
|
||||
|
||||
interface RoiKpiBarProps {
|
||||
roi: RoiResult
|
||||
currency: string
|
||||
horizonYears: number
|
||||
}
|
||||
|
||||
function RoiKpiCard({
|
||||
label, value, sub, icon, iconColor, valueColor,
|
||||
}: {
|
||||
label: string
|
||||
value: string
|
||||
sub?: string
|
||||
icon: React.ReactNode
|
||||
iconColor: string
|
||||
valueColor?: string
|
||||
}) {
|
||||
return (
|
||||
<div data-testid="roi-kpi-card" className="bg-white rounded-xl border border-slate-200 shadow-sm p-5 flex items-start gap-4">
|
||||
<div className={`shrink-0 p-3 rounded-lg ${iconColor}`}>
|
||||
{icon}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-xs font-medium text-slate-500 uppercase tracking-wide mb-1">{label}</p>
|
||||
<p className={`text-2xl font-bold font-mono leading-tight ${valueColor ?? 'text-slate-900'}`}>{value}</p>
|
||||
{sub && <p className="text-xs text-slate-400 mt-0.5 leading-snug">{sub}</p>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function RoiKpiBar({ roi, currency, horizonYears }: RoiKpiBarProps) {
|
||||
const paybackStr = !isFinite(roi.paybackMonths)
|
||||
? 'No se recupera'
|
||||
: roi.paybackMonths === 0
|
||||
? 'Inmediato'
|
||||
: roi.paybackMonths < 24
|
||||
? `${roi.paybackMonths.toFixed(1)} meses`
|
||||
: `${roi.paybackYears.toFixed(1)} años`
|
||||
|
||||
const roiStr = !isFinite(roi.roiAnnualPercent)
|
||||
? '∞%'
|
||||
: formatPercent(roi.roiAnnualPercent)
|
||||
|
||||
const savingsPosColor = roi.savingsPerExecution > 0
|
||||
? 'text-emerald-700'
|
||||
: roi.savingsPerExecution < 0
|
||||
? 'text-red-600'
|
||||
: 'text-slate-900'
|
||||
|
||||
const annualPosColor = roi.annualSavings > 0
|
||||
? 'text-emerald-700'
|
||||
: roi.annualSavings < 0
|
||||
? 'text-red-600'
|
||||
: 'text-slate-900'
|
||||
|
||||
const cumulativePosColor = roi.cumulativeSavingsHorizon > 0
|
||||
? 'text-emerald-700'
|
||||
: roi.cumulativeSavingsHorizon < 0
|
||||
? 'text-red-600'
|
||||
: 'text-slate-900'
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-2 lg:grid-cols-5 gap-4 mb-8">
|
||||
<RoiKpiCard
|
||||
label="Ahorro por ejecución"
|
||||
value={formatCurrency(roi.savingsPerExecution, currency)}
|
||||
sub={`${formatPercent(roi.savingsPerExecutionPercent)} vs. escenario actual`}
|
||||
icon={<TrendingDown className="h-5 w-5 text-emerald-600" />}
|
||||
iconColor="bg-emerald-50"
|
||||
valueColor={savingsPosColor}
|
||||
/>
|
||||
<RoiKpiCard
|
||||
label="Ahorro anual"
|
||||
value={formatCurrency(roi.annualSavings, currency)}
|
||||
sub="sobre la frecuencia configurada"
|
||||
icon={<DollarSign className="h-5 w-5 text-emerald-600" />}
|
||||
iconColor="bg-emerald-50"
|
||||
valueColor={annualPosColor}
|
||||
/>
|
||||
<RoiKpiCard
|
||||
label="Payback"
|
||||
value={paybackStr}
|
||||
sub={roi.breaksEvenInHorizon ? '✓ Dentro del horizonte' : 'Fuera del horizonte de análisis'}
|
||||
icon={<Calendar className="h-5 w-5 text-primary" />}
|
||||
iconColor="bg-primary/10"
|
||||
valueColor={roi.breaksEvenInHorizon ? 'text-emerald-700' : 'text-slate-900'}
|
||||
/>
|
||||
<RoiKpiCard
|
||||
label={`Acumulado (${horizonYears} años)`}
|
||||
value={formatCurrency(roi.cumulativeSavingsHorizon, currency)}
|
||||
sub="ahorro neto de inversión inicial"
|
||||
icon={<BarChart3 className="h-5 w-5 text-violet-600" />}
|
||||
iconColor="bg-violet-50"
|
||||
valueColor={cumulativePosColor}
|
||||
/>
|
||||
<RoiKpiCard
|
||||
label="ROI anual"
|
||||
value={roiStr}
|
||||
sub="retorno anual sobre la inversión"
|
||||
icon={<Percent className="h-5 w-5 text-amber-600" />}
|
||||
iconColor="bg-amber-50"
|
||||
valueColor={roi.roiAnnualPercent > 0 ? 'text-emerald-700' : roi.roiAnnualPercent < 0 ? 'text-red-600' : 'text-slate-900'}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
95
src/features/report/TopSavingsChart.tsx
Normal file
95
src/features/report/TopSavingsChart.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
import { useMemo } from 'react'
|
||||
import ReactECharts from 'echarts-for-react'
|
||||
import { formatCurrency } from '@/lib/format'
|
||||
import type { ActivitySimResult } from '@/domain/types'
|
||||
|
||||
interface TopSavingsChartProps {
|
||||
perActivityActual: ActivitySimResult[]
|
||||
perActivityAutomated: ActivitySimResult[]
|
||||
currency: string
|
||||
}
|
||||
|
||||
function truncate(s: string, max: number) {
|
||||
return s.length > max ? s.slice(0, max - 1) + '…' : s
|
||||
}
|
||||
|
||||
export function TopSavingsChart({
|
||||
perActivityActual,
|
||||
perActivityAutomated,
|
||||
currency,
|
||||
}: TopSavingsChartProps) {
|
||||
const option = useMemo(() => {
|
||||
const automatedMap = new Map(perActivityAutomated.map((a) => [a.activityId, a]))
|
||||
|
||||
const top5 = perActivityActual
|
||||
.map((act) => {
|
||||
const auto = automatedMap.get(act.activityId)
|
||||
const savings = act.expectedTotalCost - (auto?.expectedTotalCost ?? act.expectedTotalCost)
|
||||
return { name: act.activityName, value: savings }
|
||||
})
|
||||
.filter((s) => s.value > 0)
|
||||
.sort((a, b) => b.value - a.value)
|
||||
.slice(0, 5)
|
||||
|
||||
if (top5.length === 0) return null
|
||||
|
||||
const reversed = [...top5].reverse()
|
||||
|
||||
return {
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
axisPointer: { type: 'shadow' },
|
||||
formatter: (params: unknown[]) => {
|
||||
const p = params[0] as { name: string; value: number }
|
||||
return `<div style="font-size:12px"><b>${p.name}</b><br/>Ahorro: <strong>${formatCurrency(p.value, currency)}</strong></div>`
|
||||
},
|
||||
},
|
||||
grid: { left: 12, right: 32, top: 8, bottom: 8, containLabel: true },
|
||||
xAxis: {
|
||||
type: 'value',
|
||||
axisLabel: {
|
||||
fontSize: 10,
|
||||
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: reversed.map((s) => truncate(s.name, 22)),
|
||||
axisLabel: { fontSize: 10, color: '#64748b' },
|
||||
axisTick: { show: false },
|
||||
axisLine: { show: false },
|
||||
},
|
||||
series: [
|
||||
{
|
||||
type: 'bar',
|
||||
data: reversed.map((s) => s.value),
|
||||
barMaxWidth: 32,
|
||||
itemStyle: { color: '#10b981', borderRadius: [0, 4, 4, 0] },
|
||||
label: {
|
||||
show: true,
|
||||
position: 'right',
|
||||
fontSize: 10,
|
||||
formatter: (p: { value: number }) => formatCurrency(p.value, currency),
|
||||
color: '#10b981',
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
}, [perActivityActual, perActivityAutomated, currency])
|
||||
|
||||
if (!option) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-48 text-xs text-slate-400">
|
||||
Sin actividades con ahorro positivo
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return <ReactECharts option={option} style={{ height: Math.max(180, option.yAxis.data.length * 48) }} notMerge />
|
||||
}
|
||||
91
src/features/report/TransparencySection.tsx
Normal file
91
src/features/report/TransparencySection.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
import { AlertTriangle } from 'lucide-react'
|
||||
import { formatPercent } from '@/lib/format'
|
||||
import type { Activity } from '@/domain/types'
|
||||
import type { RoiResult } from '@/domain/roi'
|
||||
|
||||
// Umbral de ROI a partir del cual se dispara la advertencia de verosimilitud.
|
||||
// Un ROI > 1000% anual es matemáticamente posible pero comercialmente inverosímil:
|
||||
// indica casi siempre un input incorrecto (frecuencia sobreestimada, costo auto subestimado
|
||||
// o inversión subestimada). Documentado en METHODOLOGY_AUTOMATED_COST.md.
|
||||
const ROI_HIGH_THRESHOLD = 1000
|
||||
|
||||
interface TransparencySectionProps {
|
||||
activities: Activity[]
|
||||
investment: number
|
||||
roi?: RoiResult
|
||||
}
|
||||
|
||||
export function TransparencySection({ activities, investment, roi }: TransparencySectionProps) {
|
||||
const zeroCostAutomatable = activities.filter(
|
||||
(a) => a.automatable && a.automatedCostFixed === 0 && a.automatedTimeMinutes === 0
|
||||
)
|
||||
const investmentIsZero = investment === 0
|
||||
|
||||
// Caso 4: ROI > 1000% finito (Infinity no aplica — ya lo cubre el caso de investment=0)
|
||||
const roiIsUnusuallyHigh =
|
||||
roi !== undefined &&
|
||||
Number.isFinite(roi.roiAnnualPercent) &&
|
||||
roi.roiAnnualPercent > ROI_HIGH_THRESHOLD
|
||||
|
||||
if (zeroCostAutomatable.length === 0 && !investmentIsZero && !roiIsUnusuallyHigh) return null
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-amber-200 bg-amber-50 p-5 space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertTriangle className="h-4 w-4 text-amber-600 shrink-0" />
|
||||
<h3 className="text-sm font-semibold text-amber-800">Transparencia metodológica</h3>
|
||||
</div>
|
||||
|
||||
{zeroCostAutomatable.length > 0 && (
|
||||
<div className="text-xs text-amber-700 leading-relaxed space-y-1.5">
|
||||
<p className="font-medium">
|
||||
{zeroCostAutomatable.length === 1
|
||||
? '1 actividad automatizable sin costo configurado:'
|
||||
: `${zeroCostAutomatable.length} actividades automatizables sin costo configurado:`}
|
||||
</p>
|
||||
<ul className="list-disc list-inside space-y-0.5 ml-1">
|
||||
{zeroCostAutomatable.map((a) => (
|
||||
<li key={a.id} className="font-mono">
|
||||
{a.name || a.bpmnElementId}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="text-amber-600">
|
||||
Estas actividades se incluyen en el escenario automatizado con costo cero, lo que puede
|
||||
inflar artificialmente el ahorro calculado. Configurá sus costos en el workspace para
|
||||
un análisis realista.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{investmentIsZero && (
|
||||
<p className="text-xs text-amber-700 leading-relaxed">
|
||||
<span className="font-medium">Inversión en automatización no configurada.</span>{' '}
|
||||
El payback aparece como inmediato y el ROI como infinito porque no hay inversión inicial.
|
||||
Configurá la inversión total en la tab "Global" del workspace para un análisis
|
||||
financiero completo.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{roiIsUnusuallyHigh && roi && (
|
||||
<div className="text-xs text-amber-700 leading-relaxed space-y-1.5">
|
||||
<p>
|
||||
<span className="font-medium">
|
||||
ROI inusualmente alto detectado: {formatPercent(roi.roiAnnualPercent)}.
|
||||
</span>{' '}
|
||||
Un ROI superior al {formatPercent(ROI_HIGH_THRESHOLD)} anual suele indicar uno de estos casos:
|
||||
</p>
|
||||
<ul className="list-disc list-inside space-y-0.5 ml-1">
|
||||
<li>Frecuencia anual del proceso sobreestimada</li>
|
||||
<li>Costo automatizado por ejecución subestimado</li>
|
||||
<li>Inversión en automatización subestimada</li>
|
||||
</ul>
|
||||
<p className="text-amber-600">
|
||||
Revisá estos inputs antes de presentar el reporte al cliente.
|
||||
Ver <span className="font-mono">METHODOLOGY_AUTOMATED_COST.md</span> para guía de estimación.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user