feat(sprint-3): biblioteca, recursos TO-BE, ROI mejorado e identidad de marca

Sprint 3 completo — 7 etapas:

- Etapa 1-2: biblioteca de procesos con agrupación por cliente (Dexie v4,
  LibraryPage, library-store, groupId/tags en Process)
- Etapa 3: modal de configuración global por proceso (horizonte, frecuencia,
  inversión, moneda) con validación y persistencia
- Etapa 4: panel de recursos rediseñado con soporte de unidades y minutos
  de utilización (utilizationMinutes + units)
- Etapa 5: recursos en escenario automatizado — motor de simulación,
  UI de edición en ActivityPanel, comparación AS-IS/TO-BE en reporte
- Etapa 6: PDF recursos automatizados, automatedAssignedResources requerido,
  561 tests pasando
- Etapa 7: identidad de marca — AppHeader gradiente naranja/magenta con logo
  InQuality en 5 páginas, favicon con logo real, logo/marca navegan a home

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-27 11:49:43 -03:00
parent d2746f07d1
commit 67e259e6ed
57 changed files with 4372 additions and 82 deletions

View File

@@ -10,6 +10,7 @@ type SortKey = 'activityName' | 'expectedDirectCost' | 'expectedIndirectCost' |
interface ActivitiesTableProps {
perActivity: ActivitySimResult[]
pairedBreakdown?: Record<string, Array<{ resourceId: string; resourceName: string; cost: number }>>
mode: HeatmapMode
currency: string
highlightedId: string | null
@@ -23,7 +24,7 @@ function SortIcon({ column, sortKey, dir }: { column: SortKey; sortKey: SortKey;
: <ArrowDown className="h-3 w-3 ml-1 text-primary" />
}
export function ActivitiesTable({ perActivity, mode, currency, highlightedId, onRowClick }: ActivitiesTableProps) {
export function ActivitiesTable({ perActivity, pairedBreakdown, mode, currency, highlightedId, onRowClick }: ActivitiesTableProps) {
const [sortKey, setSortKey] = useState<SortKey>('expectedTotalCost')
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('desc')
const [expandedRows, setExpandedRows] = useState<Set<string>>(new Set())
@@ -167,23 +168,50 @@ export function ActivitiesTable({ perActivity, mode, currency, highlightedId, on
<TableCell />
<TableCell colSpan={7} className="py-2 pl-4 pr-6">
<div className="space-y-1">
{fixedCostExpected > 0 && (
<div className="flex justify-between text-xs text-slate-500">
<span>Fijo (insumos, licencias)</span>
<span className="font-mono">{formatCurrency(fixedCostExpected, currency)}</span>
</div>
)}
{act.resourceCostBreakdown.map((r) => (
<div key={r.resourceId} className="flex justify-between text-xs text-slate-500">
<span>{r.resourceName}</span>
<span className="font-mono">{formatCurrency(r.cost, currency)}</span>
</div>
))}
{act.resourceCostBreakdown.length === 0 && fixedCostExpected === 0 && (
<p className="text-xs text-slate-400">Sin desglose disponible</p>
)}
{act.resourceCostBreakdown.length === 0 && fixedCostExpected > 0 && (
<p className="text-xs text-slate-400 mt-0.5">Sin recursos asignados costo es 100% fijo</p>
{pairedBreakdown?.[act.activityId] ? (
<>
<p className="text-[10px] font-semibold text-slate-400 uppercase tracking-wide mt-1">AS-IS</p>
{pairedBreakdown[act.activityId].map((r) => (
<div key={r.resourceId} className="flex justify-between text-xs">
<span className="text-slate-600">{r.resourceName}</span>
<span className="font-mono">{formatCurrency(r.cost, currency)}</span>
</div>
))}
{pairedBreakdown[act.activityId].length === 0 && (
<p className="text-xs text-slate-400 italic">Sin recursos AS-IS</p>
)}
<p className="text-[10px] font-semibold text-slate-400 uppercase tracking-wide mt-2">TO-BE</p>
{act.resourceCostBreakdown.length === 0
? <p className="text-xs text-slate-400 italic">Sin recursos TO-BE</p>
: act.resourceCostBreakdown.map((r) => (
<div key={r.resourceId} className="flex justify-between text-xs">
<span className="text-slate-600">{r.resourceName}</span>
<span className="font-mono">{formatCurrency(r.cost, currency)}</span>
</div>
))
}
</>
) : (
<>
{fixedCostExpected > 0 && (
<div className="flex justify-between text-xs text-slate-500">
<span>Fijo (insumos, licencias)</span>
<span className="font-mono">{formatCurrency(fixedCostExpected, currency)}</span>
</div>
)}
{act.resourceCostBreakdown.map((r) => (
<div key={r.resourceId} className="flex justify-between text-xs text-slate-500">
<span>{r.resourceName}</span>
<span className="font-mono">{formatCurrency(r.cost, currency)}</span>
</div>
))}
{act.resourceCostBreakdown.length === 0 && fixedCostExpected === 0 && (
<p className="text-xs text-slate-400">Sin desglose disponible</p>
)}
{act.resourceCostBreakdown.length === 0 && fixedCostExpected > 0 && (
<p className="text-xs text-slate-400 mt-0.5">Sin recursos asignados costo es 100% fijo</p>
)}
</>
)}
</div>
</TableCell>

View File

@@ -1,4 +1,4 @@
import { useCallback, useMemo, useRef, useState } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useParams, useNavigate } from '@tanstack/react-router'
import { AlertTriangle, Home, Info } from 'lucide-react'
import { Button } from '@/components/ui/button'
@@ -27,6 +27,8 @@ import { TransparencySection } from './TransparencySection'
import { AutomationScenarioNote } from './AutomationScenarioNote'
import { MethodologyFooter } from './MethodologyFooter'
import type { HeatmapMode } from '@/lib/colors'
import { useSimulationStore } from '@/store/simulation-store'
import { AppHeader } from '@/components/AppHeader'
export function ReportPage() {
const { processId } = useParams({ from: '/report/$processId' })
@@ -35,6 +37,12 @@ export function ReportPage() {
const { toast } = useToast()
const [heatmapMode, setHeatmapMode] = useState<HeatmapMode>('relative')
const { lastPersistedExecutedAt, loadLatestForProcess } = useSimulationStore()
// Cargar executedAt persistido si el usuario llega directo al reporte (sin pasar por workspace)
useEffect(() => {
if (process) loadLatestForProcess(process.id)
}, [process?.id, loadLatestForProcess])
// Set de bpmnElementIds marcados como automatizables — debe estar antes de los early returns
const automatableIds = useMemo(
@@ -56,6 +64,14 @@ export function ReportPage() {
})
}, [simulation, process])
// Breakdown del escenario actual, indexado por activityId, para comparación lado a lado en tab Automatizado
const actualBreakdownById = useMemo(() => {
if (!simulation?.result) return undefined
return Object.fromEntries(
simulation.result.perActivity.map((a) => [a.activityId, a.resourceCostBreakdown])
)
}, [simulation?.result])
// Estado y refs para el tab "Actual"
const [selectedIdActual, setSelectedIdActual] = useState<string | null>(null)
const heatmapRef = useRef<HeatmapCanvasHandle>(null)
@@ -219,9 +235,29 @@ export function ReportPage() {
// marcadas como automatizables. Sin automatizables, los tabs muestran datos idénticos al actual.
const hasAutomatedScenario = hasAutomatedResult && automatableIds.size > 0
const isStale = Boolean(process && process.updatedAt > (lastPersistedExecutedAt ?? 0))
return (
<TooltipProvider>
<div className="min-h-screen bg-slate-50">
<AppHeader />
{/* Banner stale — cambios sin simular */}
{isStale && (
<div className="flex items-center justify-between px-6 py-2.5 text-[12px] font-medium"
style={{ background: '#fffbeb', borderBottom: '1px solid #f59e0b', color: '#92400e' }}>
<div className="flex items-center gap-2">
<AlertTriangle className="h-3.5 w-3.5 shrink-0" />
Los resultados pueden estar desactualizados hay cambios sin simular.
</div>
<button
onClick={() => navigate({ to: '/workspace/$processId', params: { processId } })}
className="underline hover:no-underline shrink-0 ml-4"
>
Ir al workspace
</button>
</div>
)}
<div className="max-w-screen-xl mx-auto px-6 py-10">
{/* ── Header (fuera de los tabs: aplica a todos los escenarios) ──── */}
@@ -449,6 +485,7 @@ export function ReportPage() {
</div>
<ActivitiesTable
perActivity={resultAutomated!.perActivity}
pairedBreakdown={actualBreakdownById}
mode={heatmapMode}
currency={process.currency}
highlightedId={selectedIdAuto}

View File

@@ -58,9 +58,11 @@ function RoiKpiCard({
export function RoiKpiBar({ roi, currency, horizonYears }: RoiKpiBarProps) {
const paybackStr = formatPayback(roi.paybackMonths)
const roiStr = !isFinite(roi.roiAnnualPercent)
? '∞%'
: formatPercent(roi.roiAnnualPercent)
const roiIsUndefined = !isFinite(roi.roiAnnualPercent)
const roiStr = roiIsUndefined ? '—' : formatPercent(roi.roiAnnualPercent)
const roiSub = roiIsUndefined
? 'Configurá la inversión del proyecto en Configuración Global'
: 'retorno anual sobre la inversión'
const savingsPosColor = roi.savingsPerExecution > 0
? 'text-emerald-700'
@@ -117,10 +119,10 @@ export function RoiKpiBar({ roi, currency, horizonYears }: RoiKpiBarProps) {
<RoiKpiCard
label="ROI anual"
value={roiStr}
sub="retorno anual sobre la inversión"
sub={roiSub}
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'}
valueColor={roiIsUndefined ? 'text-slate-400' : roi.roiAnnualPercent > 0 ? 'text-emerald-700' : roi.roiAnnualPercent < 0 ? 'text-red-600' : 'text-slate-900'}
/>
</div>
</div>