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
46
index.html
@@ -1,26 +1,26 @@
|
||||
<!doctype html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<!doctype html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
|
||||
<title>Process Cost Platform — 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." />
|
||||
<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." />
|
||||
|
||||
<!-- Open Graph / Social sharing -->
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:title" content="Process Cost Platform — 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:locale" content="es_PY" />
|
||||
<!-- Open Graph / Social sharing -->
|
||||
<meta property="og:type" content="website" />
|
||||
<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:locale" content="es_PY" />
|
||||
|
||||
<!-- Twitter / WhatsApp fallback -->
|
||||
<meta name="twitter:card" content="summary" />
|
||||
<meta name="twitter:title" content="Process Cost Platform" />
|
||||
<meta name="twitter:description" content="Cuantificá el costo operativo de tus procesos BPMN en minutos." />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
<!-- Twitter / WhatsApp fallback -->
|
||||
<meta name="twitter:card" content="summary" />
|
||||
<meta name="twitter:title" content="InQ ROI" />
|
||||
<meta name="twitter:description" content="Cuantificá el costo operativo de tus procesos BPMN en minutos." />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
BIN
methodology-initial-commit.tar.gz
Normal file
@@ -2,7 +2,7 @@ export function AppFooter() {
|
||||
return (
|
||||
<footer className="mt-12 py-4 border-t border-slate-100 text-center">
|
||||
<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>
|
||||
</footer>
|
||||
)
|
||||
|
||||
@@ -8,7 +8,7 @@ const buttonVariants = cva(
|
||||
{
|
||||
variants: {
|
||||
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',
|
||||
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',
|
||||
|
||||
@@ -65,16 +65,16 @@ export function ComparisonTable({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-slate-200 overflow-hidden">
|
||||
<div className="rounded-xl border border-inq-orange/20 overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader className="bg-slate-50">
|
||||
<TableHeader style={{ background: '#FFF8ED', borderBottom: '1px solid #F59845' }}>
|
||||
<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="w-8 text-xs text-amber-800/70" />
|
||||
<TableHead className="text-xs text-amber-800/70 font-medium uppercase tracking-wide">Actividad</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 text-amber-800/70 font-medium uppercase tracking-wide">Costo automatizado</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'))}
|
||||
>
|
||||
<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" />
|
||||
</span>
|
||||
</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>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@@ -93,8 +93,10 @@ export function ComparisonTable({
|
||||
: row.savings < 0
|
||||
? 'text-red-600'
|
||||
: 'text-slate-400'
|
||||
const rowClass = row.automatable ? '' : 'bg-slate-50/60'
|
||||
const textClass = row.automatable ? 'text-slate-800' : 'text-slate-500'
|
||||
return (
|
||||
<TableRow key={row.activityId}>
|
||||
<TableRow key={row.activityId} className={rowClass}>
|
||||
<TableCell className="py-2">
|
||||
{row.automatable && (
|
||||
<Bot
|
||||
@@ -103,22 +105,24 @@ export function ComparisonTable({
|
||||
/>
|
||||
)}
|
||||
</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}
|
||||
</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)}
|
||||
</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)}
|
||||
</TableCell>
|
||||
<TableCell className={`text-xs font-mono font-semibold text-right ${savingsColor}`}>
|
||||
{row.savings > 0 ? '+' : ''}
|
||||
{formatCurrency(row.savings, currency)}
|
||||
<TableCell className={`text-xs font-mono font-semibold text-right ${row.automatable ? savingsColor : 'text-slate-300'}`}>
|
||||
{row.automatable
|
||||
? (row.savings > 0 ? '+' : '') + formatCurrency(row.savings, currency)
|
||||
: '—'}
|
||||
</TableCell>
|
||||
<TableCell className={`text-xs font-mono text-right ${savingsColor}`}>
|
||||
{row.savings > 0 ? '+' : ''}
|
||||
{formatPercent(row.savingsPercent)}
|
||||
<TableCell className={`text-xs font-mono text-right ${row.automatable ? savingsColor : 'text-slate-300'}`}>
|
||||
{row.automatable
|
||||
? (row.savings > 0 ? '+' : '') + formatPercent(row.savingsPercent)
|
||||
: '—'}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
|
||||
@@ -29,9 +29,9 @@ export function CumulativeSavingsChart({
|
||||
formatter: `Payback\n${roi.paybackYears.toFixed(1)} años`,
|
||||
position: 'insideEndTop',
|
||||
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]
|
||||
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>Acumulado neto: <strong style="color:${val >= 0 ? '#16A34A' : '#EF4444'}">${formatCurrency(val, currency)}</strong></div>
|
||||
</div>`
|
||||
},
|
||||
},
|
||||
@@ -88,18 +88,18 @@ export function CumulativeSavingsChart({
|
||||
type: 'line',
|
||||
data: years.map((y, i) => [y, values[i]]),
|
||||
smooth: false,
|
||||
lineStyle: { color: '#3b82f6', width: 2.5 },
|
||||
lineStyle: { color: '#F59845', 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)' },
|
||||
{ offset: 0, color: 'rgba(245,152,69,0.18)' },
|
||||
{ offset: 1, color: 'rgba(245,152,69,0.02)' },
|
||||
],
|
||||
},
|
||||
},
|
||||
itemStyle: { color: '#3b82f6' },
|
||||
itemStyle: { color: '#F59845' },
|
||||
symbolSize: 6,
|
||||
markLine: {
|
||||
silent: true,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 { ActivitySimResult } from '@/domain/types'
|
||||
|
||||
@@ -21,7 +21,7 @@ export function HeatmapLegend({ perActivity, mode, currency }: HeatmapLegendProp
|
||||
<div className="flex items-center gap-3 mt-3">
|
||||
<span className="text-xs text-slate-500 shrink-0">Bajo costo</span>
|
||||
<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>
|
||||
<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 type { RoiResult } from '@/domain/roi'
|
||||
|
||||
@@ -8,6 +8,28 @@ interface RoiKpiBarProps {
|
||||
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({
|
||||
label, value, sub, icon, iconColor, valueColor, compact,
|
||||
}: {
|
||||
@@ -20,13 +42,13 @@ function RoiKpiCard({
|
||||
compact?: boolean
|
||||
}) {
|
||||
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}`}>
|
||||
<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-2.5 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={`${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>}
|
||||
</div>
|
||||
</div>
|
||||
@@ -52,55 +74,55 @@ export function RoiKpiBar({ roi, currency, horizonYears }: RoiKpiBarProps) {
|
||||
? 'text-red-600'
|
||||
: 'text-slate-900'
|
||||
|
||||
const cumulativePosColor = roi.cumulativeSavingsHorizon > 0
|
||||
? 'text-emerald-700'
|
||||
: roi.cumulativeSavingsHorizon < 0
|
||||
? 'text-red-600'
|
||||
: 'text-slate-900'
|
||||
const cumulativeSub = roi.cumulativeSavingsHorizon > 0
|
||||
? `neto de inversión inicial · ${horizonYears} años`
|
||||
: 'sin recuperación neta en el horizonte'
|
||||
|
||||
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'}
|
||||
compact
|
||||
/>
|
||||
<RoiKpiCard
|
||||
label={`Acumulado (${horizonYears} años)`}
|
||||
<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="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'}
|
||||
sub={cumulativeSub}
|
||||
/>
|
||||
|
||||
{/* KPIs secundarios — grid 4 columnas */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
|
||||
<RoiKpiCard
|
||||
label="Ahorro por ejecución"
|
||||
value={formatCurrency(roi.savingsPerExecution, currency)}
|
||||
sub={`${formatPercent(roi.savingsPerExecutionPercent)} vs. escenario actual`}
|
||||
icon={<TrendingDown className="h-4 w-4 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-4 w-4 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-4 w-4 text-primary" />}
|
||||
iconColor="bg-primary/10"
|
||||
valueColor={roi.breaksEvenInHorizon ? 'text-emerald-700' : 'text-slate-900'}
|
||||
compact
|
||||
/>
|
||||
<RoiKpiCard
|
||||
label="ROI anual"
|
||||
value={roiStr}
|
||||
sub="retorno anual sobre la inversión"
|
||||
icon={<Percent className="h-4 w-4 text-amber-600" />}
|
||||
iconColor="bg-amber-50"
|
||||
valueColor={roi.roiAnnualPercent > 0 ? 'text-emerald-700' : roi.roiAnnualPercent < 0 ? 'text-red-600' : 'text-slate-900'}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -70,13 +70,13 @@ export function TopSavingsChart({
|
||||
type: 'bar',
|
||||
data: reversed.map((s) => s.value),
|
||||
barMaxWidth: 32,
|
||||
itemStyle: { color: '#10b981', borderRadius: [0, 4, 4, 0] },
|
||||
itemStyle: { color: '#16A34A', borderRadius: [0, 4, 4, 0] },
|
||||
label: {
|
||||
show: true,
|
||||
position: 'right',
|
||||
fontSize: 10,
|
||||
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> = {
|
||||
role: 'bg-blue-100 text-blue-700',
|
||||
role: 'bg-slate-100 text-slate-600',
|
||||
person: 'bg-green-100 text-green-700',
|
||||
system: 'bg-purple-100 text-purple-700',
|
||||
equipment: 'bg-amber-100 text-amber-700',
|
||||
|
||||
@@ -120,7 +120,7 @@ export function buildCostCompositionOption(
|
||||
labelLine: { length: 10, length2: 8 },
|
||||
emphasis: { label: { fontSize: 13, fontWeight: 'bold' } },
|
||||
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' } }] : []),
|
||||
],
|
||||
}],
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
// 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_MID = { r: 245, g: 158, b: 11 } // #f59e0b
|
||||
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 {
|
||||
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": [
|
||||
{
|
||||
"itemStyle": {
|
||||
"color": "#3b82f6",
|
||||
"color": "#F59845",
|
||||
},
|
||||
"name": "Costo directo",
|
||||
"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 |