Standardize page headers and chart tokens across all pages

PageHeader on Budget, Proyecciones, Pensiones, Planificador,
Municipalidad, Sincronización, Financiamientos, Asistente. Pensiones
fund identity and Municipalidad meter/charge series now use the
validated chart tokens (color-mix for translucent fills). Planificador
stat cards on StatTile; deterministic chart rendering everywhere.
Municipalidad page title now matches its nav/breadcrumb label.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Carlos Escalante
2026-07-04 12:53:30 -06:00
parent 9907ff265e
commit 41d4306e86
8 changed files with 117 additions and 151 deletions

View File

@@ -8,6 +8,7 @@ import {
} from "@copilotkit/react-core/v2"; } from "@copilotkit/react-core/v2";
import { useCopilotAction } from "@copilotkit/react-core"; import { useCopilotAction } from "@copilotkit/react-core";
import { Sparkles } from "lucide-react"; import { Sparkles } from "lucide-react";
import { PageHeader } from '@/components/page-header';
import { SpendingSummaryCard, type SpendingSummaryArgs } from "@/components/chat/ChatCards"; import { SpendingSummaryCard, type SpendingSummaryArgs } from "@/components/chat/ChatCards";
const STATIC_SUGGESTIONS = { const STATIC_SUGGESTIONS = {
@@ -87,13 +88,11 @@ export default function Asistente() {
// max-w wrapper are all min-h-0 flex columns) — no vh math. // max-w wrapper are all min-h-0 flex columns) — no vh math.
<div className="flex flex-1 min-h-0 flex-col"> <div className="flex flex-1 min-h-0 flex-col">
<div className="mb-4"> <div className="mb-4">
<h1 className="text-2xl font-bold tracking-tight flex items-center gap-2" style={{ fontFamily: "var(--font-heading)" }}> <PageHeader
<Sparkles className="w-5 h-5 text-primary" /> icon={Sparkles}
Asistente title="Asistente"
</h1> subtitle="Pregúntale a WealthySmart sobre tus finanzas."
<p className="text-sm text-muted-foreground"> />
Pregúntale a WealthySmart sobre tus finanzas.
</p>
</div> </div>
<div className="flex-1 min-h-0 rounded-xl border border-border overflow-hidden bg-card"> <div className="flex-1 min-h-0 rounded-xl border border-border overflow-hidden bg-card">

View File

@@ -12,6 +12,7 @@ import {
import { useBudget } from '@/hooks/useBudget'; import { useBudget } from '@/hooks/useBudget';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { PageHeader } from '@/components/page-header';
import ErrorState from '@/components/ErrorState'; import ErrorState from '@/components/ErrorState';
import PeriodNavigator from '@/components/PeriodNavigator'; import PeriodNavigator from '@/components/PeriodNavigator';
import MonthlyDetail from '@/components/budget/MonthlyDetail'; import MonthlyDetail from '@/components/budget/MonthlyDetail';
@@ -128,13 +129,11 @@ export default function Budget() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Header */} {/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
{cameFromProyecciones && ( {cameFromProyecciones && (
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
className="mr-4 text-muted-foreground" className="-mb-4 text-muted-foreground"
onClick={() => navigate('/proyecciones')} onClick={() => navigate('/proyecciones')}
aria-label="Volver a Proyecciones" aria-label="Volver a Proyecciones"
> >
@@ -142,9 +141,11 @@ export default function Budget() {
Proyecciones Proyecciones
</Button> </Button>
)} )}
<Calculator className="w-6 h-6 text-primary" /> <PageHeader
<h1 className="text-2xl font-bold tracking-tight">Presupuesto</h1> icon={Calculator}
</div> title="Presupuesto"
subtitle="Proyección y transacciones del mes"
actions={
<PeriodNavigator <PeriodNavigator
label={String(year)} label={String(year)}
onPrev={() => setYear(year - 1)} onPrev={() => setYear(year - 1)}
@@ -152,7 +153,8 @@ export default function Budget() {
prevDisabled={year <= MIN_YEAR} prevDisabled={year <= MIN_YEAR}
nextDisabled={year >= MAX_YEAR} nextDisabled={year >= MAX_YEAR}
/> />
</div> }
/>
<Tabs defaultValue="overview"> <Tabs defaultValue="overview">
<TabsList> <TabsList>

View File

@@ -19,6 +19,7 @@ import {
TableRow, TableRow,
} from '@/components/ui/table'; } from '@/components/ui/table';
import ErrorState from '@/components/ErrorState'; import ErrorState from '@/components/ErrorState';
import { PageHeader } from '@/components/page-header';
import ConvertToInstallmentsDialog from '@/components/transactions/ConvertToInstallmentsDialog'; import ConvertToInstallmentsDialog from '@/components/transactions/ConvertToInstallmentsDialog';
function formatDate(iso: string) { function formatDate(iso: string) {
@@ -104,14 +105,11 @@ export default function Financiamientos() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center gap-3"> <PageHeader
<Layers className="w-6 h-6 text-primary" /> icon={Layers}
<h1 className="text-2xl font-bold tracking-tight">Financiamientos</h1> title="Financiamientos"
</div> subtitle="Compras Tasa Cero en cuotas sin intereses — las cuotas futuras ya cuentan en el presupuesto de sus meses."
<p className="text-sm text-muted-foreground -mt-4"> />
Compras Tasa Cero pagadas en cuotas mensuales sin intereses. Las cuotas
futuras ya cuentan en el presupuesto de sus meses.
</p>
{/* Summary */} {/* Summary */}
<div className="grid gap-4 sm:grid-cols-3"> <div className="grid gap-4 sm:grid-cols-3">

View File

@@ -1,6 +1,7 @@
import { useState, useMemo, useCallback, useRef } from 'react'; import { useState, useMemo, useCallback, useRef } from 'react';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import ErrorState from '@/components/ErrorState'; import ErrorState from '@/components/ErrorState';
import { PageHeader } from '@/components/page-header';
import PensionAssumptionsDialog, { type PensionAssumptions } from '@/components/PensionAssumptionsDialog'; import PensionAssumptionsDialog, { type PensionAssumptions } from '@/components/PensionAssumptionsDialog';
import { import {
LineChart, LineChart,
@@ -103,7 +104,7 @@ const FUNDS_DEFAULT: Record<FundKey, FundDef> = {
key: 'FCL', key: 'FCL',
name: 'FCL', name: 'FCL',
fullName: 'Fondo de Capitalización Laboral', fullName: 'Fondo de Capitalización Laboral',
color: '#3b82f6', color: 'var(--chart-5)',
startBalance: 650_468, startBalance: 650_468,
monthlyContribution: 150_000, monthlyContribution: 150_000,
annualRate: 7.5, annualRate: 7.5,
@@ -115,7 +116,7 @@ const FUNDS_DEFAULT: Record<FundKey, FundDef> = {
key: 'ROP', key: 'ROP',
name: 'ROP', name: 'ROP',
fullName: 'Régimen Obligatorio de Pensiones', fullName: 'Régimen Obligatorio de Pensiones',
color: '#10b981', color: 'var(--chart-1)',
startBalance: 18_684_765, startBalance: 18_684_765,
monthlyContribution: 120_000, monthlyContribution: 120_000,
annualRate: 6.0, annualRate: 6.0,
@@ -127,7 +128,7 @@ const FUNDS_DEFAULT: Record<FundKey, FundDef> = {
key: 'VOL', key: 'VOL',
name: 'VOL', name: 'VOL',
fullName: 'Fondo Voluntario', fullName: 'Fondo Voluntario',
color: '#f43f5e', color: 'var(--chart-4)',
startBalance: 2_500_381, startBalance: 2_500_381,
monthlyContribution: 400_000, monthlyContribution: 400_000,
annualRate: 8.0, annualRate: 8.0,
@@ -480,17 +481,11 @@ export default function Pensions() {
/> />
)} )}
{/* ── Page Header ─────────────────────────────────────────────────── */} {/* ── Page Header ─────────────────────────────────────────────────── */}
<div className="flex items-center gap-3"> <PageHeader
<div className="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center"> icon={PiggyBank}
<PiggyBank className="w-5 h-5 text-primary" /> title="Pensiones"
</div> subtitle="Seguimiento de aportes, rendimientos y proyecciones"
<div> />
<h1 className="text-2xl font-bold font-heading">Pensiones</h1>
<p className="text-sm text-muted-foreground">
Seguimiento de aportes, rendimientos y proyecciones
</p>
</div>
</div>
{/* ── Section 1: Fund Overview Cards ──────────────────────────────── */} {/* ── Section 1: Fund Overview Cards ──────────────────────────────── */}
<section className="space-y-3"> <section className="space-y-3">
@@ -606,7 +601,7 @@ export default function Pensions() {
className="flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium transition-all border cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" className="flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium transition-all border cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
style={{ style={{
borderColor: fund.color, borderColor: fund.color,
background: active ? fund.color + '22' : 'transparent', background: active ? `color-mix(in oklab, ${fund.color} 13%, transparent)` : 'transparent',
color: active ? fund.color : 'var(--muted-foreground)', color: active ? fund.color : 'var(--muted-foreground)',
}} }}
> >
@@ -652,6 +647,7 @@ export default function Pensions() {
stroke={FUNDS[key].color} stroke={FUNDS[key].color}
strokeWidth={2} strokeWidth={2}
dot={false} dot={false}
isAnimationActive={false}
activeDot={{ r: 4 }} activeDot={{ r: 4 }}
/> />
) : null, ) : null,

View File

@@ -4,6 +4,8 @@ import { Line, LineChart, XAxis, YAxis } from 'recharts';
import { Telescope } from 'lucide-react'; import { Telescope } from 'lucide-react';
import { getYearlyProjection } from '@/lib/api'; import { getYearlyProjection } from '@/lib/api';
import { PageHeader } from '@/components/page-header';
import { StatTile } from '@/components/stat-tile';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
@@ -78,15 +80,11 @@ export default function Planificador() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center gap-3"> <PageHeader
<Telescope className="w-6 h-6 text-primary" aria-hidden="true" /> icon={Telescope}
<div> title="Planificador"
<h1 className="text-2xl font-bold tracking-tight">Planificador</h1> subtitle="¿Qué pasa si ahorrás un poco más cada mes?"
<p className="text-sm text-muted-foreground"> />
¿Qué pasa si ahorrás un poco más cada mes?
</p>
</div>
</div>
<Card> <Card>
<CardContent className="p-4 grid grid-cols-2 lg:grid-cols-4 gap-4"> <CardContent className="p-4 grid grid-cols-2 lg:grid-cols-4 gap-4">
@@ -138,41 +136,18 @@ export default function Planificador() {
</Card> </Card>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<Card> <StatTile label="Ritmo actual" value={formatCRC(baselineEnd)} />
<CardContent className="p-4"> <StatTile label="Escenario" value={formatCRC(scenarioEnd)} />
<p className="text-xs text-muted-foreground uppercase tracking-wider"> <StatTile
Ritmo actual label="Diferencia"
</p> value={`+${formatCRC(scenarioEnd - baselineEnd)}`}
<p data-sensitive className="text-xl font-bold font-mono"> className="[&_span[data-sensitive]]:text-primary"
{formatCRC(baselineEnd)} />
</p>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<p className="text-xs text-muted-foreground uppercase tracking-wider">
Escenario
</p>
<p data-sensitive className="text-xl font-bold font-mono text-primary">
{formatCRC(scenarioEnd)}
</p>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<p className="text-xs text-muted-foreground uppercase tracking-wider">
Diferencia
</p>
<p data-sensitive className="text-xl font-bold font-mono text-emerald-500">
+{formatCRC(scenarioEnd - baselineEnd)}
</p>
</CardContent>
</Card>
</div> </div>
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="text-sm uppercase tracking-wider text-muted-foreground"> <CardTitle className="text-sm font-medium text-muted-foreground">
Crecimiento comparado Crecimiento comparado
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
@@ -191,8 +166,8 @@ export default function Planificador() {
<ChartTooltipContent formatter={(value) => formatCRC(Number(value))} /> <ChartTooltipContent formatter={(value) => formatCRC(Number(value))} />
} }
/> />
<Line type="monotone" dataKey="baseline" stroke="var(--chart-2)" dot={false} strokeWidth={2} /> <Line type="monotone" dataKey="baseline" stroke="var(--chart-2)" dot={false} strokeWidth={2} isAnimationActive={false} />
<Line type="monotone" dataKey="scenario" stroke="var(--chart-1)" dot={false} strokeWidth={2} /> <Line type="monotone" dataKey="scenario" stroke="var(--chart-1)" dot={false} strokeWidth={2} isAnimationActive={false} />
</LineChart> </LineChart>
</ChartContainer> </ChartContainer>
<p className="text-xs text-muted-foreground mt-2"> <p className="text-xs text-muted-foreground mt-2">

View File

@@ -5,6 +5,7 @@ import { useBudget } from '@/hooks/useBudget';
import { formatAmount } from '@/lib/format'; import { formatAmount } from '@/lib/format';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { Card, CardContent } from '@/components/ui/card'; import { Card, CardContent } from '@/components/ui/card';
import { PageHeader } from '@/components/page-header';
import ErrorState from '@/components/ErrorState'; import ErrorState from '@/components/ErrorState';
import PeriodNavigator from '@/components/PeriodNavigator'; import PeriodNavigator from '@/components/PeriodNavigator';
import YearlyOverview from '@/components/budget/YearlyOverview'; import YearlyOverview from '@/components/budget/YearlyOverview';
@@ -30,12 +31,11 @@ export default function Proyecciones() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Header */} <PageHeader
<div className="flex items-center justify-between"> icon={TrendingUp}
<div className="flex items-center gap-3"> title="Proyecciones"
<TrendingUp className="w-6 h-6 text-primary" /> subtitle="Presupuesto anual mes a mes"
<h1 className="text-2xl font-bold tracking-tight">Proyecciones</h1> actions={
</div>
<PeriodNavigator <PeriodNavigator
label={String(year)} label={String(year)}
onPrev={() => setYear(year - 1)} onPrev={() => setYear(year - 1)}
@@ -43,7 +43,8 @@ export default function Proyecciones() {
prevDisabled={year <= MIN_YEAR} prevDisabled={year <= MIN_YEAR}
nextDisabled={year >= MAX_YEAR} nextDisabled={year >= MAX_YEAR}
/> />
</div> }
/>
{/* Annual summary cards */} {/* Annual summary cards */}
{projection && ( {projection && (

View File

@@ -2,6 +2,7 @@ import { useState, useCallback, useRef, useMemo } from 'react';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { MONTH_NAMES_ES_SHORT as MONTH_NAMES_ES } from '@/lib/dates'; import { MONTH_NAMES_ES_SHORT as MONTH_NAMES_ES } from '@/lib/dates';
import MeterLabelsDialog from '@/components/MeterLabelsDialog'; import MeterLabelsDialog from '@/components/MeterLabelsDialog';
import { PageHeader } from '@/components/page-header';
import ErrorState from '@/components/ErrorState'; import ErrorState from '@/components/ErrorState';
import { import {
BarChart, BarChart,
@@ -51,13 +52,13 @@ import {
// ─── Constants ─────────────────────────────────────────────────────────────── // ─── Constants ───────────────────────────────────────────────────────────────
const METER_COLORS: Record<string, string> = { const METER_COLORS: Record<string, string> = {
'7335': '#3b82f6', '7335': 'var(--chart-5)',
'7345': '#10b981', '7345': 'var(--chart-1)',
'9345': '#f59e0b', '9345': 'var(--chart-2)',
}; };
const DEFAULT_METER_COLOR = '#8b5cf6'; const DEFAULT_METER_COLOR = 'var(--chart-3)';
// ─── Utilities ─────────────────────────────────────────────────────────────── // ─── Utilities ───────────────────────────────────────────────────────────────
@@ -105,8 +106,11 @@ function getMeterIds(readings: WaterMeterReading[]): string[] {
// ─── Charge Trend Data ─────────────────────────────────────────────────────── // ─── Charge Trend Data ───────────────────────────────────────────────────────
// Fixed categorical slots (validated palette); series beyond the 5th read
// muted — never cycle hues.
const CHARGE_COLORS = [ const CHARGE_COLORS = [
'#ef4444', '#f59e0b', '#10b981', '#3b82f6', '#8b5cf6', '#ec4899', '#06b6d4', '#84cc16', 'var(--chart-1)', 'var(--chart-2)', 'var(--chart-3)', 'var(--chart-4)',
'var(--chart-5)', 'var(--muted-foreground)',
]; ];
interface ChargeTrendPoint { interface ChargeTrendPoint {
@@ -306,17 +310,11 @@ export default function ServiciosMunicipales() {
/> />
)} )}
{/* ── Page Header ─────────────────────────────────────────────────── */} {/* ── Page Header ─────────────────────────────────────────────────── */}
<div className="flex items-center gap-3"> <PageHeader
<div className="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center"> icon={Droplets}
<Droplets className="w-5 h-5 text-primary" /> title="Municipalidad"
</div> subtitle="Municipalidad de Belén — recibos y consumo de agua"
<div> />
<h1 className="text-2xl font-bold font-heading">Servicios Municipales</h1>
<p className="text-sm text-muted-foreground">
Municipalidad de Belén recibos y consumo de agua
</p>
</div>
</div>
{/* ── Summary Cards ───────────────────────────────────────────────── */} {/* ── Summary Cards ───────────────────────────────────────────────── */}
<section className="grid grid-cols-2 sm:grid-cols-4 gap-3"> <section className="grid grid-cols-2 sm:grid-cols-4 gap-3">
@@ -546,7 +544,7 @@ export default function ServiciosMunicipales() {
type="monotone" type="monotone"
dataKey={name} dataKey={name}
name={name} name={name}
stroke={CHARGE_COLORS[i % CHARGE_COLORS.length]} stroke={CHARGE_COLORS[Math.min(i, CHARGE_COLORS.length - 1)]}
strokeWidth={2} strokeWidth={2}
dot={{ r: 3 }} dot={{ r: 3 }}
activeDot={{ r: 5 }} activeDot={{ r: 5 }}

View File

@@ -11,6 +11,7 @@ import { formatRelativeAge } from '@/lib/dates';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card'; import { Card, CardContent } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { PageHeader } from '@/components/page-header';
import ErrorState from '@/components/ErrorState'; import ErrorState from '@/components/ErrorState';
import { Skeleton } from '@/components/ui/skeleton'; import { Skeleton } from '@/components/ui/skeleton';
@@ -38,16 +39,11 @@ export default function SyncStatus() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center justify-between"> <PageHeader
<div className="flex items-center gap-3"> icon={RefreshCw}
<RefreshCw className="w-6 h-6 text-primary" aria-hidden="true" /> title="Sincronización"
<div> subtitle="Estado de los flujos automáticos de datos (n8n y tipo de cambio)"
<h1 className="text-2xl font-bold tracking-tight">Sincronización</h1> actions={
<p className="text-sm text-muted-foreground">
Estado de los flujos automáticos de datos (n8n y tipo de cambio)
</p>
</div>
</div>
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
@@ -60,7 +56,8 @@ export default function SyncStatus() {
/> />
Actualizar Actualizar
</Button> </Button>
</div> }
/>
{query.isError ? ( {query.isError ? (
<ErrorState <ErrorState