Inicio: hover-cards on stat tiles

Gasto del ciclo reveals the by-source breakdown, Próximas cuotas the
full active-plan list, Pensión last-period rendimientos per fund.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Carlos Escalante
2026-07-04 14:00:59 -06:00
parent 061a49f3b1
commit 5033348320
2 changed files with 92 additions and 2 deletions

View File

@@ -2,6 +2,11 @@ import { Link } from "react-router-dom";
import { cn } from "@/lib/utils";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
HoverCard,
HoverCardContent,
HoverCardTrigger,
} from "@/components/ui/hover-card";
import { Skeleton } from "@/components/ui/skeleton";
/** Stat-tile contract (dataviz skill): label · value · optional delta ·
@@ -21,6 +26,8 @@ export interface StatTileProps {
/** Makes the whole tile a link (adds hover border + focus ring). */
to?: string;
ariaLabel?: string;
/** Extra detail revealed on hover (HoverCard). */
hoverContent?: React.ReactNode;
/** Set false for non-monetary values that shouldn't blur (counts, dates). */
sensitive?: boolean;
className?: string;
@@ -35,6 +42,7 @@ export function StatTile({
children,
to,
ariaLabel,
hoverContent,
sensitive = true,
className,
}: StatTileProps) {
@@ -83,8 +91,7 @@ export function StatTile({
</Card>
);
if (!to) return card;
return (
const wrapped = to ? (
<Link
to={to}
aria-label={ariaLabel ?? label}
@@ -92,5 +99,17 @@ export function StatTile({
>
{card}
</Link>
) : (
card
);
if (!hoverContent) return wrapped;
return (
<HoverCard>
<HoverCardTrigger render={<div className="h-full" />}>
{wrapped}
</HoverCardTrigger>
<HoverCardContent className="w-80">{hoverContent}</HoverCardContent>
</HoverCard>
);
}

View File

@@ -25,6 +25,36 @@ import { Skeleton } from "@/components/ui/skeleton";
const CYCLE = currentBudgetCycle();
const SOURCE_LABELS: Record<string, string> = {
CREDIT_CARD: "Tarjeta",
CASH: "Efectivo",
TRANSFER: "Transferencias",
};
function HoverRows({
title,
rows,
}: {
title: string;
rows: { label: string; value: string }[];
}) {
return (
<div className="space-y-1.5">
<p className="text-sm font-medium">{title}</p>
<div className="space-y-1">
{rows.map((r) => (
<div key={r.label} className="flex justify-between gap-3 text-xs">
<span className="text-muted-foreground truncate">{r.label}</span>
<span data-sensitive className="font-mono shrink-0">
{r.value}
</span>
</div>
))}
</div>
</div>
);
}
function GastoDelCicloCard() {
const q = useQuery({
queryKey: ["budget", "month", CYCLE.year, CYCLE.month],
@@ -44,6 +74,25 @@ function GastoDelCicloCard() {
loading={q.isPending}
to="/budget"
ariaLabel="Ver presupuesto"
hoverContent={
q.data ? (
<HoverRows
title="Desglose por fuente"
rows={[
...q.data.actuals_by_source
.filter((s) => s.count > 0)
.map((s) => ({
label: `${SOURCE_LABELS[s.source] ?? s.source} (${s.count})`,
value: formatAmount(s.net, "CRC"),
})),
{
label: "Gran total egresos",
value: formatAmount(q.data.gran_total_egresos, "CRC"),
},
]}
/>
) : undefined
}
>
{balance !== null && (
<p className="text-xs text-muted-foreground">
@@ -105,6 +154,17 @@ function ProximasCuotasCard() {
loading={q.isPending}
to="/financiamientos"
ariaLabel="Ver financiamientos"
hoverContent={
upcoming.length > 0 ? (
<HoverRows
title="Planes activos"
rows={upcoming.map((p) => ({
label: `${p.merchant} (${p.cuotas_billed}/${p.num_installments})`,
value: `${formatShortDate(p.next_cuota_date!)} · ${formatAmount(nextCuotaOf(p), p.currency)}`,
}))}
/>
) : undefined
}
>
{topThree.length > 0 && (
<ul className="space-y-0.5 text-xs text-muted-foreground">
@@ -146,6 +206,17 @@ function PensionCard() {
loading={q.isPending}
to="/pensions"
ariaLabel="Ver pensiones"
hoverContent={
q.data && q.data.length > 0 ? (
<HoverRows
title="Último período por fondo"
rows={q.data.map((f) => ({
label: `${f.fund} · rendimientos`,
value: `+${formatAmount(f.rendimientos, "CRC")}`,
}))}
/>
) : undefined
}
>
{q.data && q.data.length > 0 && (
<ul className="space-y-0.5 text-xs text-muted-foreground">