UI foundations: PageHeader, StatTile, validated chart palette, registry primitives

- PageHeader + StatTile standardize the four header and three stat-tile
  variants found in the audit; Inicio and Salarios migrated as proof.
- chart tokens replaced with a teal-anchored categorical scale, CVD-
  ordered and validated (six checks, light + dark surfaces).
- Registry adds: empty, field, item, spinner, progress, toggle-group,
  input-group, popover, kbd (+ toggle dep).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Carlos Escalante
2026-07-04 12:23:21 -06:00
parent 10d9bd209f
commit a95486481b
17 changed files with 1328 additions and 247 deletions

View File

@@ -1,6 +1,6 @@
import { useState } from "react";
import { Link, useNavigate } from "react-router-dom";
import { ArrowRight, Sparkles } from "lucide-react";
import { ArrowRight, Home, Sparkles } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import {
@@ -16,61 +16,15 @@ import {
formatShortDate,
} from "@/lib/dates";
import { cn } from "@/lib/utils";
import { PageHeader } from "@/components/page-header";
import { StatTile } from "@/components/stat-tile";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Skeleton } from "@/components/ui/skeleton";
const CYCLE = currentBudgetCycle();
function StatCardShell({
to,
ariaLabel,
children,
}: {
to: string;
ariaLabel: string;
children: React.ReactNode;
}) {
return (
<Link
to={to}
aria-label={ariaLabel}
className="block rounded-xl focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<Card className="h-full transition-colors hover:border-primary/40 cursor-pointer">
{children}
</Card>
</Link>
);
}
function StatValue({
value,
loading,
className,
}: {
value: string | null;
loading: boolean;
className?: string;
}) {
if (loading) return <Skeleton className="h-8 w-36" />;
return (
<span
data-sensitive
className={cn("text-2xl font-bold font-mono", className)}
>
{value ?? "—"}
</span>
);
}
function GastoDelCicloCard() {
const q = useQuery({
queryKey: ["budget", "month", CYCLE.year, CYCLE.month],
@@ -83,44 +37,35 @@ function GastoDelCicloCard() {
const balance = q.data?.net_balance ?? null;
return (
<StatCardShell to="/budget" ariaLabel="Ver presupuesto">
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
Gasto del ciclo
</CardTitle>
<CardDescription>
Ciclo al 18 {MONTH_NAMES_ES_SHORT[CYCLE.month].toLowerCase()}
</CardDescription>
</CardHeader>
<CardContent className="space-y-1">
<StatValue
loading={q.isPending}
value={
q.isError ? null : spend !== null ? formatAmount(spend, "CRC") : null
}
/>
{balance !== null && (
<p className="text-xs text-muted-foreground">
Balance proyectado:{" "}
<span
data-sensitive
className={cn(
"font-mono font-medium",
balance >= 0 ? "text-primary" : "text-destructive",
)}
>
{balance >= 0 ? "+" : "-"}
{formatAmount(balance, "CRC")}
</span>
</p>
)}
{q.isError && (
<p className="text-xs text-muted-foreground">
No se pudo cargar el ciclo.
</p>
)}
</CardContent>
</StatCardShell>
<StatTile
label="Gasto del ciclo"
description={`Ciclo al 18 ${MONTH_NAMES_ES_SHORT[CYCLE.month].toLowerCase()}`}
value={q.isError || spend === null ? null : formatAmount(spend, "CRC")}
loading={q.isPending}
to="/budget"
ariaLabel="Ver presupuesto"
>
{balance !== null && (
<p className="text-xs text-muted-foreground">
Balance proyectado:{" "}
<span
data-sensitive
className={cn(
"font-mono font-medium",
balance >= 0 ? "text-primary" : "text-destructive",
)}
>
{balance >= 0 ? "+" : "-"}
{formatAmount(balance, "CRC")}
</span>
</p>
)}
{q.isError && (
<p className="text-xs text-muted-foreground">
No se pudo cargar el ciclo.
</p>
)}
</StatTile>
);
}
@@ -149,45 +94,40 @@ function ProximasCuotasCard() {
const topThree = upcoming.slice(0, 3);
return (
<StatCardShell to="/financiamientos" ariaLabel="Ver financiamientos">
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
Próximas cuotas
</CardTitle>
<CardDescription>
{nextDate
? `Tasa Cero · próximo cobro ${formatShortDate(nextDate)}`
: "Tasa Cero"}
</CardDescription>
</CardHeader>
<CardContent className="space-y-2">
<StatValue
loading={q.isPending}
value={q.isError ? null : formatAmount(nextBatchTotal, "CRC")}
/>
{topThree.length > 0 && (
<ul className="space-y-0.5 text-xs text-muted-foreground">
{topThree.map((p) => (
<li key={p.id} className="flex justify-between gap-2">
<span className="truncate">{p.merchant}</span>
<span data-sensitive className="font-mono shrink-0">
{formatShortDate(p.next_cuota_date!)} ·{" "}
{formatAmount(nextCuotaOf(p), p.currency)}
</span>
</li>
))}
</ul>
)}
{q.data && (
<p className="text-xs text-muted-foreground">
Restante total:{" "}
<span data-sensitive className="font-mono font-medium">
{formatAmount(q.data.total_remaining, "CRC")}
</span>
</p>
)}
</CardContent>
</StatCardShell>
<StatTile
label="Próximas cuotas"
description={
nextDate
? `Tasa Cero · próximo cobro ${formatShortDate(nextDate)}`
: "Tasa Cero"
}
value={q.isError ? null : formatAmount(nextBatchTotal, "CRC")}
loading={q.isPending}
to="/financiamientos"
ariaLabel="Ver financiamientos"
>
{topThree.length > 0 && (
<ul className="space-y-0.5 text-xs text-muted-foreground">
{topThree.map((p) => (
<li key={p.id} className="flex justify-between gap-2">
<span className="truncate">{p.merchant}</span>
<span data-sensitive className="font-mono shrink-0">
{formatShortDate(p.next_cuota_date!)} ·{" "}
{formatAmount(nextCuotaOf(p), p.currency)}
</span>
</li>
))}
</ul>
)}
{q.data && (
<p className="text-xs text-muted-foreground">
Restante total:{" "}
<span data-sensitive className="font-mono font-medium">
{formatAmount(q.data.total_remaining, "CRC")}
</span>
</p>
)}
</StatTile>
);
}
@@ -196,39 +136,30 @@ function PensionCard() {
queryKey: ["pension-fund-summary"],
queryFn: () => getPensionFundSummary().then((r) => r.data),
});
const total = q.data
? q.data.reduce((s, f) => s + f.saldo_final, 0)
: null;
const total = q.data ? q.data.reduce((s, f) => s + f.saldo_final, 0) : null;
return (
<StatCardShell to="/pensions" ariaLabel="Ver pensiones">
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
Pensión
</CardTitle>
<CardDescription>Saldo total de fondos</CardDescription>
</CardHeader>
<CardContent className="space-y-2">
<StatValue
loading={q.isPending}
value={
q.isError ? null : total !== null ? formatAmount(total, "CRC") : null
}
/>
{q.data && q.data.length > 0 && (
<ul className="space-y-0.5 text-xs text-muted-foreground">
{q.data.map((f) => (
<li key={f.fund} className="flex justify-between gap-2">
<span>{f.fund}</span>
<span data-sensitive className="font-mono">
{formatAmount(f.saldo_final, "CRC")}
</span>
</li>
))}
</ul>
)}
</CardContent>
</StatCardShell>
<StatTile
label="Pensión"
description="Saldo total de fondos"
value={q.isError || total === null ? null : formatAmount(total, "CRC")}
loading={q.isPending}
to="/pensions"
ariaLabel="Ver pensiones"
>
{q.data && q.data.length > 0 && (
<ul className="space-y-0.5 text-xs text-muted-foreground">
{q.data.map((f) => (
<li key={f.fund} className="flex justify-between gap-2">
<span>{f.fund}</span>
<span data-sensitive className="font-mono">
{formatAmount(f.saldo_final, "CRC")}
</span>
</li>
))}
</ul>
)}
</StatTile>
);
}
@@ -327,17 +258,11 @@ function RecentTransactionsCard() {
export default function Inicio() {
return (
<div className="space-y-6">
<div>
<h1
className="text-2xl font-bold tracking-tight"
style={{ fontFamily: "var(--font-heading)" }}
>
Inicio
</h1>
<p className="text-sm text-muted-foreground">
Tu panorama financiero de hoy.
</p>
</div>
<PageHeader
icon={Home}
title="Inicio"
subtitle="Tu panorama financiero de hoy."
/>
<div className="grid gap-4 md:grid-cols-3">
<GastoDelCicloCard />

View File

@@ -1,11 +1,13 @@
import { useMemo } from 'react';
import { useQuery } from '@tanstack/react-query';
import { type ColumnDef } from '@tanstack/react-table';
import { Landmark, RefreshCw, Hash, CalendarDays, Banknote, Download } from 'lucide-react';
import { Landmark, RefreshCw, Download } from 'lucide-react';
import { type Transaction, type SalariosSummary, getSalarios, getSalariosSummary } from '@/lib/api';
import { type Transaction, getSalarios, getSalariosSummary } from '@/lib/api';
import { formatAmount, formatDate } from '@/lib/format';
import ErrorState from '@/components/ErrorState';
import { PageHeader } from '@/components/page-header';
import { StatTile } from '@/components/stat-tile';
import { DataTable } from '@/components/ui/data-table';
import { DataTableColumnHeader } from '@/components/ui/data-table-column-header';
import { Card, CardContent } from '@/components/ui/card';
@@ -87,67 +89,48 @@ export default function Salarios() {
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center">
<Landmark className="w-5 h-5 text-primary" />
</div>
<div>
<h1 className="text-2xl font-bold font-heading">Salarios</h1>
<p className="text-sm text-muted-foreground">Historial de depósitos salariales</p>
</div>
</div>
<Button
variant="outline"
size="sm"
onClick={() => window.open('/api/v1/transactions/export?transaction_type=SALARY', '_blank')}
title="Descargar salarios como CSV"
>
<Download className="w-4 h-4 mr-2" aria-hidden="true" />
CSV
</Button>
<Button variant="ghost" size="icon" onClick={fetchData} title="Refresh" aria-label="Refresh">
<RefreshCw className={loading ? 'w-4 h-4 animate-spin' : 'w-4 h-4'} />
</Button>
</div>
<PageHeader
icon={Landmark}
title="Salarios"
subtitle="Historial de depósitos salariales"
actions={
<>
<Button
variant="outline"
size="sm"
onClick={() => window.open('/api/v1/transactions/export?transaction_type=SALARY', '_blank')}
title="Descargar salarios como CSV"
>
<Download className="w-4 h-4 mr-2" aria-hidden="true" />
CSV
</Button>
<Button variant="ghost" size="icon" onClick={fetchData} title="Refresh" aria-label="Refresh">
<RefreshCw className={loading ? 'w-4 h-4 animate-spin' : 'w-4 h-4'} />
</Button>
</>
}
/>
{/* Summary cards */}
{summary && (
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<Card>
<CardContent className="p-4">
<div className="flex items-center gap-2 text-muted-foreground mb-1">
<Hash className="w-4 h-4" />
<span className="text-xs font-medium uppercase tracking-wider">Depósitos</span>
</div>
<span className="text-2xl font-bold font-mono">{summary.count}</span>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="flex items-center gap-2 text-muted-foreground mb-1">
<Banknote className="w-4 h-4" />
<span className="text-xs font-medium uppercase tracking-wider">Total acumulado</span>
</div>
<span data-sensitive className="text-2xl font-bold font-mono text-primary">
{formatAmount(summary.total_amount, 'CRC')}
</span>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="flex items-center gap-2 text-muted-foreground mb-1">
<CalendarDays className="w-4 h-4" />
<span className="text-xs font-medium uppercase tracking-wider">Último depósito</span>
</div>
<span className="text-2xl font-bold font-mono">
{summary.latest_date ? formatDate(summary.latest_date) : '—'}
</span>
</CardContent>
</Card>
</div>
)}
{/* Summary tiles */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<StatTile
label="Depósitos"
value={summary ? String(summary.count) : null}
loading={query.isPending}
sensitive={false}
/>
<StatTile
label="Total acumulado"
value={summary ? formatAmount(summary.total_amount, 'CRC') : null}
loading={query.isPending}
/>
<StatTile
label="Último depósito"
value={summary?.latest_date ? formatDate(summary.latest_date) : null}
loading={query.isPending}
sensitive={false}
/>
</div>
{/* Data table */}
{query.isError ? (