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:
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user