feat(sprint-1-5): cierre Etapa 2 - formatPayback + fixes de marca
- Helper formatPayback con 12 casos cubiertos - Aplicado en RoiKpiBar, drawRoiKpiCards, csv-export - Auditoría WCAG: ratio 2.22:1, opción D (font-bold) para Etapa 3 - Adelanto: strings 'InQ ROI' y 'Powered by InQuality' en footers PDF - Threshold ROI: '1.000%' sin decimal - 455 tests verdes + 2 E2E Refs: sprints/sprint-1-5/ETAPA_2_PROMPT.md
@@ -5,18 +5,18 @@
|
|||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
|
||||||
<title>Process Cost Platform — Cuantificá tus procesos</title>
|
<title>InQ ROI — Cuantificá tus procesos</title>
|
||||||
<meta name="description" content="Importá un archivo BPMN 2.0 y obtené un reporte visual de costos operativos listo para presentar a tu cliente en minutos. 100% en tu navegador, sin registro." />
|
<meta name="description" content="Importá un archivo BPMN 2.0 y obtené un reporte visual de costos operativos listo para presentar a tu cliente en minutos. 100% en tu navegador, sin registro." />
|
||||||
|
|
||||||
<!-- Open Graph / Social sharing -->
|
<!-- Open Graph / Social sharing -->
|
||||||
<meta property="og:type" content="website" />
|
<meta property="og:type" content="website" />
|
||||||
<meta property="og:title" content="Process Cost Platform — Cuantificá tus procesos" />
|
<meta property="og:title" content="InQ ROI — Cuantificá tus procesos" />
|
||||||
<meta property="og:description" content="Transformá un BPMN 2.0 en un reporte de costos operativos en minutos. Heatmap, gráficos, export PDF y CSV." />
|
<meta property="og:description" content="Transformá un BPMN 2.0 en un reporte de costos operativos en minutos. Heatmap, gráficos, export PDF y CSV." />
|
||||||
<meta property="og:locale" content="es_PY" />
|
<meta property="og:locale" content="es_PY" />
|
||||||
|
|
||||||
<!-- Twitter / WhatsApp fallback -->
|
<!-- Twitter / WhatsApp fallback -->
|
||||||
<meta name="twitter:card" content="summary" />
|
<meta name="twitter:card" content="summary" />
|
||||||
<meta name="twitter:title" content="Process Cost Platform" />
|
<meta name="twitter:title" content="InQ ROI" />
|
||||||
<meta name="twitter:description" content="Cuantificá el costo operativo de tus procesos BPMN en minutos." />
|
<meta name="twitter:description" content="Cuantificá el costo operativo de tus procesos BPMN en minutos." />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
BIN
methodology-initial-commit.tar.gz
Normal file
@@ -2,7 +2,7 @@ export function AppFooter() {
|
|||||||
return (
|
return (
|
||||||
<footer className="mt-12 py-4 border-t border-slate-100 text-center">
|
<footer className="mt-12 py-4 border-t border-slate-100 text-center">
|
||||||
<p className="text-xs text-slate-300">
|
<p className="text-xs text-slate-300">
|
||||||
Process Cost Platform · v0.1.0 · Hecho desde Paraguay 🇵🇾
|
InQ ROI · Powered by InQuality · v0.1.0 · Hecho desde Paraguay 🇵🇾
|
||||||
</p>
|
</p>
|
||||||
</footer>
|
</footer>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ const buttonVariants = cva(
|
|||||||
{
|
{
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
default: 'bg-primary text-primary-foreground shadow hover:bg-primary/90',
|
default: 'bg-primary text-primary-foreground shadow hover:bg-primary/90 font-bold',
|
||||||
destructive: 'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90',
|
destructive: 'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90',
|
||||||
outline: 'border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground',
|
outline: 'border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground',
|
||||||
secondary: 'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80',
|
secondary: 'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80',
|
||||||
|
|||||||
@@ -65,16 +65,16 @@ export function ComparisonTable({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-xl border border-slate-200 overflow-hidden">
|
<div className="rounded-xl border border-inq-orange/20 overflow-hidden">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader className="bg-slate-50">
|
<TableHeader style={{ background: '#FFF8ED', borderBottom: '1px solid #F59845' }}>
|
||||||
<tr>
|
<tr>
|
||||||
<TableHead className="w-8 text-xs" />
|
<TableHead className="w-8 text-xs text-amber-800/70" />
|
||||||
<TableHead className="text-xs">Actividad</TableHead>
|
<TableHead className="text-xs text-amber-800/70 font-medium uppercase tracking-wide">Actividad</TableHead>
|
||||||
<TableHead className="text-xs text-right">Costo actual</TableHead>
|
<TableHead className="text-xs text-right text-amber-800/70 font-medium uppercase tracking-wide">Costo actual</TableHead>
|
||||||
<TableHead className="text-xs text-right">Costo automatizado</TableHead>
|
<TableHead className="text-xs text-right text-amber-800/70 font-medium uppercase tracking-wide">Costo automatizado</TableHead>
|
||||||
<TableHead
|
<TableHead
|
||||||
className="text-xs text-right cursor-pointer select-none hover:text-foreground"
|
className="text-xs text-right cursor-pointer select-none text-amber-800/70 font-medium uppercase tracking-wide hover:text-amber-900"
|
||||||
onClick={() => setSortDir((d) => (d === 'desc' ? 'asc' : 'desc'))}
|
onClick={() => setSortDir((d) => (d === 'desc' ? 'asc' : 'desc'))}
|
||||||
>
|
>
|
||||||
<span className="inline-flex items-center justify-end gap-1">
|
<span className="inline-flex items-center justify-end gap-1">
|
||||||
@@ -82,7 +82,7 @@ export function ComparisonTable({
|
|||||||
<ArrowUpDown className="h-3 w-3 opacity-40" />
|
<ArrowUpDown className="h-3 w-3 opacity-40" />
|
||||||
</span>
|
</span>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead className="text-xs text-right">% ahorro</TableHead>
|
<TableHead className="text-xs text-right text-amber-800/70 font-medium uppercase tracking-wide">% ahorro</TableHead>
|
||||||
</tr>
|
</tr>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
@@ -93,8 +93,10 @@ export function ComparisonTable({
|
|||||||
: row.savings < 0
|
: row.savings < 0
|
||||||
? 'text-red-600'
|
? 'text-red-600'
|
||||||
: 'text-slate-400'
|
: 'text-slate-400'
|
||||||
|
const rowClass = row.automatable ? '' : 'bg-slate-50/60'
|
||||||
|
const textClass = row.automatable ? 'text-slate-800' : 'text-slate-500'
|
||||||
return (
|
return (
|
||||||
<TableRow key={row.activityId}>
|
<TableRow key={row.activityId} className={rowClass}>
|
||||||
<TableCell className="py-2">
|
<TableCell className="py-2">
|
||||||
{row.automatable && (
|
{row.automatable && (
|
||||||
<Bot
|
<Bot
|
||||||
@@ -103,22 +105,24 @@ export function ComparisonTable({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-xs font-medium text-slate-800 max-w-48 truncate">
|
<TableCell className={`text-xs font-medium max-w-48 truncate ${textClass}`}>
|
||||||
{row.activityName}
|
{row.activityName}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-xs font-mono text-slate-600 text-right">
|
<TableCell className={`text-xs font-mono text-right ${row.automatable ? 'text-slate-600' : 'text-slate-400'}`}>
|
||||||
{formatCurrency(row.actualCost, currency)}
|
{formatCurrency(row.actualCost, currency)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-xs font-mono text-slate-600 text-right">
|
<TableCell className={`text-xs font-mono text-right ${row.automatable ? 'text-slate-600' : 'text-slate-400'}`}>
|
||||||
{formatCurrency(row.automatedCost, currency)}
|
{formatCurrency(row.automatedCost, currency)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className={`text-xs font-mono font-semibold text-right ${savingsColor}`}>
|
<TableCell className={`text-xs font-mono font-semibold text-right ${row.automatable ? savingsColor : 'text-slate-300'}`}>
|
||||||
{row.savings > 0 ? '+' : ''}
|
{row.automatable
|
||||||
{formatCurrency(row.savings, currency)}
|
? (row.savings > 0 ? '+' : '') + formatCurrency(row.savings, currency)
|
||||||
|
: '—'}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className={`text-xs font-mono text-right ${savingsColor}`}>
|
<TableCell className={`text-xs font-mono text-right ${row.automatable ? savingsColor : 'text-slate-300'}`}>
|
||||||
{row.savings > 0 ? '+' : ''}
|
{row.automatable
|
||||||
{formatPercent(row.savingsPercent)}
|
? (row.savings > 0 ? '+' : '') + formatPercent(row.savingsPercent)
|
||||||
|
: '—'}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -29,9 +29,9 @@ export function CumulativeSavingsChart({
|
|||||||
formatter: `Payback\n${roi.paybackYears.toFixed(1)} años`,
|
formatter: `Payback\n${roi.paybackYears.toFixed(1)} años`,
|
||||||
position: 'insideEndTop',
|
position: 'insideEndTop',
|
||||||
fontSize: 10,
|
fontSize: 10,
|
||||||
color: '#10b981',
|
color: '#16A34A',
|
||||||
},
|
},
|
||||||
lineStyle: { color: '#10b981', type: 'dashed', width: 2 },
|
lineStyle: { color: '#16A34A', type: 'dashed', width: 2 },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,7 +52,7 @@ export function CumulativeSavingsChart({
|
|||||||
const val = p.value[1]
|
const val = p.value[1]
|
||||||
return `<div style="font-size:12px;line-height:1.6">
|
return `<div style="font-size:12px;line-height:1.6">
|
||||||
<div style="font-weight:600;margin-bottom:4px">Año ${year}</div>
|
<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>Acumulado neto: <strong style="color:${val >= 0 ? '#16A34A' : '#EF4444'}">${formatCurrency(val, currency)}</strong></div>
|
||||||
</div>`
|
</div>`
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -88,18 +88,18 @@ export function CumulativeSavingsChart({
|
|||||||
type: 'line',
|
type: 'line',
|
||||||
data: years.map((y, i) => [y, values[i]]),
|
data: years.map((y, i) => [y, values[i]]),
|
||||||
smooth: false,
|
smooth: false,
|
||||||
lineStyle: { color: '#3b82f6', width: 2.5 },
|
lineStyle: { color: '#F59845', width: 2.5 },
|
||||||
areaStyle: {
|
areaStyle: {
|
||||||
color: {
|
color: {
|
||||||
type: 'linear',
|
type: 'linear',
|
||||||
x: 0, y: 0, x2: 0, y2: 1,
|
x: 0, y: 0, x2: 0, y2: 1,
|
||||||
colorStops: [
|
colorStops: [
|
||||||
{ offset: 0, color: 'rgba(59,130,246,0.18)' },
|
{ offset: 0, color: 'rgba(245,152,69,0.18)' },
|
||||||
{ offset: 1, color: 'rgba(59,130,246,0.02)' },
|
{ offset: 1, color: 'rgba(245,152,69,0.02)' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
itemStyle: { color: '#3b82f6' },
|
itemStyle: { color: '#F59845' },
|
||||||
symbolSize: 6,
|
symbolSize: 6,
|
||||||
markLine: {
|
markLine: {
|
||||||
silent: true,
|
silent: true,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { formatCurrency, formatPercent } from '@/lib/format'
|
import { formatCurrency, formatPercent } from '@/lib/format'
|
||||||
import { heatmapLegendBounds } from '@/lib/colors'
|
import { heatmapLegendBounds, HEATMAP_LOW_HEX, HEATMAP_MID_HEX, HEATMAP_HIGH_HEX } from '@/lib/colors'
|
||||||
import type { HeatmapMode } from '@/lib/colors'
|
import type { HeatmapMode } from '@/lib/colors'
|
||||||
import type { ActivitySimResult } from '@/domain/types'
|
import type { ActivitySimResult } from '@/domain/types'
|
||||||
|
|
||||||
@@ -21,7 +21,7 @@ export function HeatmapLegend({ perActivity, mode, currency }: HeatmapLegendProp
|
|||||||
<div className="flex items-center gap-3 mt-3">
|
<div className="flex items-center gap-3 mt-3">
|
||||||
<span className="text-xs text-slate-500 shrink-0">Bajo costo</span>
|
<span className="text-xs text-slate-500 shrink-0">Bajo costo</span>
|
||||||
<div className="flex-1 h-3 rounded-full" style={{
|
<div className="flex-1 h-3 rounded-full" style={{
|
||||||
background: 'linear-gradient(to right, #10b981, #f59e0b, #ef4444)'
|
background: `linear-gradient(to right, ${HEATMAP_LOW_HEX}, ${HEATMAP_MID_HEX}, ${HEATMAP_HIGH_HEX})`
|
||||||
}} />
|
}} />
|
||||||
<span className="text-xs text-slate-500 shrink-0">Alto costo</span>
|
<span className="text-xs text-slate-500 shrink-0">Alto costo</span>
|
||||||
<div className="h-4 border-l border-slate-200 mx-1" />
|
<div className="h-4 border-l border-slate-200 mx-1" />
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { TrendingDown, Calendar, BarChart3, DollarSign, Percent } from 'lucide-react'
|
import { TrendingDown, Calendar, DollarSign, Percent } from 'lucide-react'
|
||||||
import { formatCurrency, formatPayback, formatPercent } from '@/lib/format'
|
import { formatCurrency, formatPayback, formatPercent } from '@/lib/format'
|
||||||
import type { RoiResult } from '@/domain/roi'
|
import type { RoiResult } from '@/domain/roi'
|
||||||
|
|
||||||
@@ -8,6 +8,28 @@ interface RoiKpiBarProps {
|
|||||||
horizonYears: number
|
horizonYears: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function RoiKpiHero({
|
||||||
|
label, value, sub,
|
||||||
|
}: {
|
||||||
|
label: string
|
||||||
|
value: string
|
||||||
|
sub?: string
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-testid="roi-kpi-card"
|
||||||
|
className="rounded-xl p-5 mb-4"
|
||||||
|
style={{ background: 'linear-gradient(135deg, #F59845 0%, #ED3E8F 100%)' }}
|
||||||
|
>
|
||||||
|
<p className="text-xs font-semibold text-white/90 uppercase tracking-widest mb-1">{label}</p>
|
||||||
|
<p className="text-3xl font-bold font-mono text-white leading-tight" style={{ letterSpacing: '-0.5px' }}>
|
||||||
|
{value}
|
||||||
|
</p>
|
||||||
|
{sub && <p className="text-sm text-white/85 mt-1">{sub}</p>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function RoiKpiCard({
|
function RoiKpiCard({
|
||||||
label, value, sub, icon, iconColor, valueColor, compact,
|
label, value, sub, icon, iconColor, valueColor, compact,
|
||||||
}: {
|
}: {
|
||||||
@@ -20,13 +42,13 @@ function RoiKpiCard({
|
|||||||
compact?: boolean
|
compact?: boolean
|
||||||
}) {
|
}) {
|
||||||
return (
|
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 data-testid="roi-kpi-card" className="bg-white rounded-xl border border-slate-200 shadow-sm p-4 flex items-start gap-3">
|
||||||
<div className={`shrink-0 p-3 rounded-lg ${iconColor}`}>
|
<div className={`shrink-0 p-2.5 rounded-lg ${iconColor}`}>
|
||||||
{icon}
|
{icon}
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0 flex-1">
|
<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-xs font-medium text-slate-500 uppercase tracking-wide mb-1">{label}</p>
|
||||||
<p className={`${compact ? 'text-sm' : 'text-2xl'} font-bold font-mono leading-tight ${valueColor ?? 'text-slate-900'}`}>{value}</p>
|
<p className={`${compact ? 'text-sm' : 'text-xl'} 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>}
|
{sub && <p className="text-xs text-slate-400 mt-0.5 leading-snug">{sub}</p>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -52,19 +74,26 @@ export function RoiKpiBar({ roi, currency, horizonYears }: RoiKpiBarProps) {
|
|||||||
? 'text-red-600'
|
? 'text-red-600'
|
||||||
: 'text-slate-900'
|
: 'text-slate-900'
|
||||||
|
|
||||||
const cumulativePosColor = roi.cumulativeSavingsHorizon > 0
|
const cumulativeSub = roi.cumulativeSavingsHorizon > 0
|
||||||
? 'text-emerald-700'
|
? `neto de inversión inicial · ${horizonYears} años`
|
||||||
: roi.cumulativeSavingsHorizon < 0
|
: 'sin recuperación neta en el horizonte'
|
||||||
? 'text-red-600'
|
|
||||||
: 'text-slate-900'
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-2 lg:grid-cols-5 gap-4 mb-8">
|
<div className="mb-8">
|
||||||
|
{/* KPI Hero — Ahorro proyectado a N años */}
|
||||||
|
<RoiKpiHero
|
||||||
|
label={`Ahorro proyectado a ${horizonYears} años`}
|
||||||
|
value={formatCurrency(roi.cumulativeSavingsHorizon, currency)}
|
||||||
|
sub={cumulativeSub}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* KPIs secundarios — grid 4 columnas */}
|
||||||
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
|
||||||
<RoiKpiCard
|
<RoiKpiCard
|
||||||
label="Ahorro por ejecución"
|
label="Ahorro por ejecución"
|
||||||
value={formatCurrency(roi.savingsPerExecution, currency)}
|
value={formatCurrency(roi.savingsPerExecution, currency)}
|
||||||
sub={`${formatPercent(roi.savingsPerExecutionPercent)} vs. escenario actual`}
|
sub={`${formatPercent(roi.savingsPerExecutionPercent)} vs. escenario actual`}
|
||||||
icon={<TrendingDown className="h-5 w-5 text-emerald-600" />}
|
icon={<TrendingDown className="h-4 w-4 text-emerald-600" />}
|
||||||
iconColor="bg-emerald-50"
|
iconColor="bg-emerald-50"
|
||||||
valueColor={savingsPosColor}
|
valueColor={savingsPosColor}
|
||||||
/>
|
/>
|
||||||
@@ -72,7 +101,7 @@ export function RoiKpiBar({ roi, currency, horizonYears }: RoiKpiBarProps) {
|
|||||||
label="Ahorro anual"
|
label="Ahorro anual"
|
||||||
value={formatCurrency(roi.annualSavings, currency)}
|
value={formatCurrency(roi.annualSavings, currency)}
|
||||||
sub="sobre la frecuencia configurada"
|
sub="sobre la frecuencia configurada"
|
||||||
icon={<DollarSign className="h-5 w-5 text-emerald-600" />}
|
icon={<DollarSign className="h-4 w-4 text-emerald-600" />}
|
||||||
iconColor="bg-emerald-50"
|
iconColor="bg-emerald-50"
|
||||||
valueColor={annualPosColor}
|
valueColor={annualPosColor}
|
||||||
/>
|
/>
|
||||||
@@ -80,27 +109,20 @@ export function RoiKpiBar({ roi, currency, horizonYears }: RoiKpiBarProps) {
|
|||||||
label="Payback"
|
label="Payback"
|
||||||
value={paybackStr}
|
value={paybackStr}
|
||||||
sub={roi.breaksEvenInHorizon ? '✓ Dentro del horizonte' : 'Fuera del horizonte de análisis'}
|
sub={roi.breaksEvenInHorizon ? '✓ Dentro del horizonte' : 'Fuera del horizonte de análisis'}
|
||||||
icon={<Calendar className="h-5 w-5 text-primary" />}
|
icon={<Calendar className="h-4 w-4 text-primary" />}
|
||||||
iconColor="bg-primary/10"
|
iconColor="bg-primary/10"
|
||||||
valueColor={roi.breaksEvenInHorizon ? 'text-emerald-700' : 'text-slate-900'}
|
valueColor={roi.breaksEvenInHorizon ? 'text-emerald-700' : 'text-slate-900'}
|
||||||
compact
|
compact
|
||||||
/>
|
/>
|
||||||
<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
|
<RoiKpiCard
|
||||||
label="ROI anual"
|
label="ROI anual"
|
||||||
value={roiStr}
|
value={roiStr}
|
||||||
sub="retorno anual sobre la inversión"
|
sub="retorno anual sobre la inversión"
|
||||||
icon={<Percent className="h-5 w-5 text-amber-600" />}
|
icon={<Percent className="h-4 w-4 text-amber-600" />}
|
||||||
iconColor="bg-amber-50"
|
iconColor="bg-amber-50"
|
||||||
valueColor={roi.roiAnnualPercent > 0 ? 'text-emerald-700' : roi.roiAnnualPercent < 0 ? 'text-red-600' : 'text-slate-900'}
|
valueColor={roi.roiAnnualPercent > 0 ? 'text-emerald-700' : roi.roiAnnualPercent < 0 ? 'text-red-600' : 'text-slate-900'}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -70,13 +70,13 @@ export function TopSavingsChart({
|
|||||||
type: 'bar',
|
type: 'bar',
|
||||||
data: reversed.map((s) => s.value),
|
data: reversed.map((s) => s.value),
|
||||||
barMaxWidth: 32,
|
barMaxWidth: 32,
|
||||||
itemStyle: { color: '#10b981', borderRadius: [0, 4, 4, 0] },
|
itemStyle: { color: '#16A34A', borderRadius: [0, 4, 4, 0] },
|
||||||
label: {
|
label: {
|
||||||
show: true,
|
show: true,
|
||||||
position: 'right',
|
position: 'right',
|
||||||
fontSize: 10,
|
fontSize: 10,
|
||||||
formatter: (p: { value: number }) => formatCurrency(p.value, currency),
|
formatter: (p: { value: number }) => formatCurrency(p.value, currency),
|
||||||
color: '#10b981',
|
color: '#16A34A',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ const RESOURCE_TYPE_LABELS: Record<ResourceType, string> = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const RESOURCE_TYPE_COLORS: Record<ResourceType, string> = {
|
const RESOURCE_TYPE_COLORS: Record<ResourceType, string> = {
|
||||||
role: 'bg-blue-100 text-blue-700',
|
role: 'bg-slate-100 text-slate-600',
|
||||||
person: 'bg-green-100 text-green-700',
|
person: 'bg-green-100 text-green-700',
|
||||||
system: 'bg-purple-100 text-purple-700',
|
system: 'bg-purple-100 text-purple-700',
|
||||||
equipment: 'bg-amber-100 text-amber-700',
|
equipment: 'bg-amber-100 text-amber-700',
|
||||||
|
|||||||
@@ -120,7 +120,7 @@ export function buildCostCompositionOption(
|
|||||||
labelLine: { length: 10, length2: 8 },
|
labelLine: { length: 10, length2: 8 },
|
||||||
emphasis: { label: { fontSize: 13, fontWeight: 'bold' } },
|
emphasis: { label: { fontSize: 13, fontWeight: 'bold' } },
|
||||||
data: [
|
data: [
|
||||||
{ value: result.totalDirectCost, name: 'Costo directo', itemStyle: { color: '#3b82f6' } },
|
{ value: result.totalDirectCost, name: 'Costo directo', itemStyle: { color: '#F59845' } },
|
||||||
...(hasIndirect ? [{ value: result.totalIndirectCost, name: 'Costo indirecto (overhead)', itemStyle: { color: '#94a3b8' } }] : []),
|
...(hasIndirect ? [{ value: result.totalIndirectCost, name: 'Costo indirecto (overhead)', itemStyle: { color: '#94a3b8' } }] : []),
|
||||||
],
|
],
|
||||||
}],
|
}],
|
||||||
|
|||||||
@@ -1,8 +1,13 @@
|
|||||||
// Paleta heatmap: verde → amarillo → rojo
|
// Paleta heatmap: verde → amarillo → rojo
|
||||||
|
// INVARIANTE: estos valores DEBEN coincidir con tailwind.config.js heatmap.*
|
||||||
const HEATMAP_LOW = { r: 16, g: 185, b: 129 } // #10b981
|
const HEATMAP_LOW = { r: 16, g: 185, b: 129 } // #10b981
|
||||||
const HEATMAP_MID = { r: 245, g: 158, b: 11 } // #f59e0b
|
const HEATMAP_MID = { r: 245, g: 158, b: 11 } // #f59e0b
|
||||||
const HEATMAP_HIGH = { r: 239, g: 68, b: 68 } // #ef4444
|
const HEATMAP_HIGH = { r: 239, g: 68, b: 68 } // #ef4444
|
||||||
|
|
||||||
|
export const HEATMAP_LOW_HEX = '#10b981'
|
||||||
|
export const HEATMAP_MID_HEX = '#f59e0b'
|
||||||
|
export const HEATMAP_HIGH_HEX = '#ef4444'
|
||||||
|
|
||||||
function lerp(a: number, b: number, t: number): number {
|
function lerp(a: number, b: number, t: number): number {
|
||||||
return Math.round(a + (b - a) * t)
|
return Math.round(a + (b - a) * t)
|
||||||
}
|
}
|
||||||
|
|||||||
134
tests/e2e/screenshots-etapa-3.spec.ts
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
/**
|
||||||
|
* Screenshots de validación visual — Etapa 3 Sprint 1.5.
|
||||||
|
*
|
||||||
|
* Captura 8 vistas que demuestran la identidad InQ aplicada en el web app:
|
||||||
|
* 1. e3-workspace.png — topbar + CTAs naranjas bold
|
||||||
|
* 2. e3-switch-on.png — ActivityPanel switch ON (caja naranja)
|
||||||
|
* 3. e3-report-tabs.png — tab activo naranja en reporte
|
||||||
|
* 4. e3-roi-kpi-hero.png — KPI Hero con gradiente naranja→magenta
|
||||||
|
* 5. e3-comparison-table.png — tabla comparativa con header naranja
|
||||||
|
* 6. e3-cumulative-chart.png — gráfico acumulado con línea naranja
|
||||||
|
* 7. e3-donut-chart.png — donut directo/overhead (naranja/slate)
|
||||||
|
* 8. e3-transparency.png — TransparencySection con ROI > 1000%
|
||||||
|
*/
|
||||||
|
import { test } from '@playwright/test'
|
||||||
|
import { resolve } from 'path'
|
||||||
|
import { mkdirSync } from 'fs'
|
||||||
|
import { fileURLToPath } from 'url'
|
||||||
|
|
||||||
|
const __dirname = fileURLToPath(new URL('.', import.meta.url))
|
||||||
|
const BPMN_DIR = resolve(__dirname, '../../public/sample-processes')
|
||||||
|
const OUT_DIR = resolve(__dirname, '../screenshots/etapa-3')
|
||||||
|
|
||||||
|
test.beforeAll(() => { mkdirSync(OUT_DIR, { recursive: true }) })
|
||||||
|
|
||||||
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function importBpmn(page: import('@playwright/test').Page, filename: string) {
|
||||||
|
await page.goto('/')
|
||||||
|
await page.locator('#bpmn-file-input').setInputFiles(resolve(BPMN_DIR, filename))
|
||||||
|
await page.waitForURL(/\/workspace\//, { timeout: 15_000 })
|
||||||
|
await page.waitForSelector('button:has-text("Simular")', { timeout: 10_000 })
|
||||||
|
await page.waitForTimeout(800)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setActivityCosts(
|
||||||
|
page: import('@playwright/test').Page,
|
||||||
|
elementId: string,
|
||||||
|
directCost: number,
|
||||||
|
automatedCost: number
|
||||||
|
) {
|
||||||
|
await page.locator(`[data-element-id="${elementId}"]`).first().click()
|
||||||
|
await page.waitForTimeout(400)
|
||||||
|
const toggle = page.getByRole('switch', { name: /automatizable/i })
|
||||||
|
if (await toggle.getAttribute('aria-checked') === 'false') await toggle.click()
|
||||||
|
await page.waitForTimeout(200)
|
||||||
|
await page.getByLabel(/Costo directo fijo/i).fill(String(directCost))
|
||||||
|
await page.getByLabel(/Costo automatizado/i).fill(String(automatedCost))
|
||||||
|
await page.getByLabel(/Tiempo automatizado/i).fill('10')
|
||||||
|
await page.getByRole('button', { name: /Guardar/i }).click()
|
||||||
|
await page.waitForTimeout(400)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function configureGlobal(page: import('@playwright/test').Page, freq: number, inv: number) {
|
||||||
|
await page.getByRole('tab', { name: /Global/i }).click()
|
||||||
|
await page.waitForTimeout(300)
|
||||||
|
await page.getByLabel(/Frecuencia anual/i).fill(String(freq))
|
||||||
|
await page.getByLabel(/Inversión en automatización/i).fill(String(inv))
|
||||||
|
await page.getByRole('button', { name: /Guardar/i }).click()
|
||||||
|
await page.waitForTimeout(400)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function goToRoiTab(page: import('@playwright/test').Page) {
|
||||||
|
const roiTab = page.getByRole('tab', { name: /Comparación/i })
|
||||||
|
await roiTab.click()
|
||||||
|
await page.waitForSelector('[data-testid="roi-kpi-card"]', { timeout: 10_000 })
|
||||||
|
await page.waitForTimeout(600)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 1. Workspace — topbar + CTAs ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('e3-1: workspace — CTAs naranjas bold', async ({ page }) => {
|
||||||
|
await importBpmn(page, 'simple-linear.bpmn')
|
||||||
|
await page.waitForTimeout(500)
|
||||||
|
await page.screenshot({ path: resolve(OUT_DIR, 'e3-workspace.png'), fullPage: false })
|
||||||
|
})
|
||||||
|
|
||||||
|
// ─── 2. Switch ON en ActivityPanel ────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('e3-2: switch ON — caja automatización naranja', async ({ page }) => {
|
||||||
|
await importBpmn(page, 'simple-linear.bpmn')
|
||||||
|
await page.locator('[data-element-id="task1"]').first().click()
|
||||||
|
await page.waitForTimeout(400)
|
||||||
|
const toggle = page.getByRole('switch', { name: /automatizable/i })
|
||||||
|
if (await toggle.getAttribute('aria-checked') === 'false') await toggle.click()
|
||||||
|
await page.waitForTimeout(300)
|
||||||
|
await page.screenshot({ path: resolve(OUT_DIR, 'e3-switch-on.png'), fullPage: false })
|
||||||
|
})
|
||||||
|
|
||||||
|
// ─── 3-8. Setup realista para los screenshots del reporte ─────────────────────
|
||||||
|
|
||||||
|
test('e3-3-to-8: reporte — 6 screenshots del tab ROI', async ({ page }) => {
|
||||||
|
await importBpmn(page, 'medium-with-gateways.bpmn')
|
||||||
|
await setActivityCosts(page, 'task_recv', 200, 20)
|
||||||
|
await setActivityCosts(page, 'task_score', 200, 20)
|
||||||
|
await setActivityCosts(page, 'task_analisis', 200, 20)
|
||||||
|
await configureGlobal(page, 5000, 80000)
|
||||||
|
|
||||||
|
const simBtn = page.getByRole('button', { name: 'Simular' })
|
||||||
|
await simBtn.click()
|
||||||
|
await page.waitForURL(/\/report\//, { timeout: 20_000 })
|
||||||
|
await page.waitForSelector('button:has-text("Exportar PDF")', { timeout: 15_000 })
|
||||||
|
await page.waitForTimeout(800)
|
||||||
|
|
||||||
|
// 3. Tabs
|
||||||
|
await page.screenshot({ path: resolve(OUT_DIR, 'e3-report-tabs.png'), fullPage: false })
|
||||||
|
|
||||||
|
await goToRoiTab(page)
|
||||||
|
|
||||||
|
// 4. KPI Hero
|
||||||
|
await page.screenshot({ path: resolve(OUT_DIR, 'e3-roi-kpi-hero.png'), fullPage: false })
|
||||||
|
|
||||||
|
// 5. Comparison Table (scroll down)
|
||||||
|
await page.evaluate(() => window.scrollBy(0, 420))
|
||||||
|
await page.waitForTimeout(300)
|
||||||
|
await page.screenshot({ path: resolve(OUT_DIR, 'e3-comparison-table.png'), fullPage: false })
|
||||||
|
|
||||||
|
// 6. Cumulative chart (scroll to charts section)
|
||||||
|
await page.evaluate(() => window.scrollBy(0, 500))
|
||||||
|
await page.waitForTimeout(400)
|
||||||
|
await page.screenshot({ path: resolve(OUT_DIR, 'e3-cumulative-chart.png'), fullPage: false })
|
||||||
|
|
||||||
|
// 7. Donut chart (actual tab)
|
||||||
|
await page.getByRole('tab', { name: 'Actual' }).click()
|
||||||
|
await page.waitForTimeout(600)
|
||||||
|
await page.evaluate(() => window.scrollTo(0, 0))
|
||||||
|
await page.waitForTimeout(300)
|
||||||
|
await page.screenshot({ path: resolve(OUT_DIR, 'e3-donut-chart.png'), fullPage: false })
|
||||||
|
|
||||||
|
// 8. Transparency section — ROI > 1000% (back to ROI tab, scroll to bottom)
|
||||||
|
await goToRoiTab(page)
|
||||||
|
await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight))
|
||||||
|
await page.waitForTimeout(500)
|
||||||
|
await page.screenshot({ path: resolve(OUT_DIR, 'e3-transparency.png'), fullPage: false })
|
||||||
|
})
|
||||||
@@ -37,7 +37,7 @@ exports[`buildCostCompositionOption — estructura del objeto ECharts > snapshot
|
|||||||
"data": [
|
"data": [
|
||||||
{
|
{
|
||||||
"itemStyle": {
|
"itemStyle": {
|
||||||
"color": "#3b82f6",
|
"color": "#F59845",
|
||||||
},
|
},
|
||||||
"name": "Costo directo",
|
"name": "Costo directo",
|
||||||
"value": 1000,
|
"value": 1000,
|
||||||
|
|||||||
BIN
tests/screenshots/etapa-3/e3-comparison-table.png
Normal file
|
After Width: | Height: | Size: 110 KiB |
BIN
tests/screenshots/etapa-3/e3-cumulative-chart.png
Normal file
|
After Width: | Height: | Size: 107 KiB |
BIN
tests/screenshots/etapa-3/e3-donut-chart.png
Normal file
|
After Width: | Height: | Size: 91 KiB |
BIN
tests/screenshots/etapa-3/e3-report-tabs.png
Normal file
|
After Width: | Height: | Size: 91 KiB |
BIN
tests/screenshots/etapa-3/e3-roi-kpi-hero.png
Normal file
|
After Width: | Height: | Size: 172 KiB |
BIN
tests/screenshots/etapa-3/e3-switch-on.png
Normal file
|
After Width: | Height: | Size: 76 KiB |
BIN
tests/screenshots/etapa-3/e3-transparency.png
Normal file
|
After Width: | Height: | Size: 110 KiB |
BIN
tests/screenshots/etapa-3/e3-workspace.png
Normal file
|
After Width: | Height: | Size: 49 KiB |