Files
WealthySmart/frontend/src/components/stat-tile.tsx
Carlos Escalante 5033348320 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>
2026-07-04 14:00:59 -06:00

116 lines
3.2 KiB
TypeScript

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 ·
* optional context/detail rows. Values are pre-formatted by the caller
* (formatAmount etc.) and blur under privacy mode via data-sensitive. */
export interface StatTileProps {
label: string;
/** Small line under the label, e.g. "Ciclo al 18 jul". */
description?: string;
/** Pre-formatted headline value. null renders an em dash. */
value: string | null;
loading?: boolean;
/** Signed, pre-formatted delta plus whether it's good news (colors it). */
delta?: { text: string; positive: boolean };
/** Muted line(s) or detail rows under the value. */
children?: React.ReactNode;
/** 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;
}
export function StatTile({
label,
description,
value,
loading = false,
delta,
children,
to,
ariaLabel,
hoverContent,
sensitive = true,
className,
}: StatTileProps) {
const card = (
<Card
className={cn(
"h-full",
to && "transition-colors hover:border-primary/40 cursor-pointer",
className,
)}
>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
{label}
</CardTitle>
{description && (
<p className="text-xs text-muted-foreground/80">{description}</p>
)}
</CardHeader>
<CardContent className="space-y-2">
<div className="flex items-baseline gap-2">
{loading ? (
<Skeleton className="h-8 w-32" />
) : (
<span
{...(sensitive ? { "data-sensitive": true } : {})}
className="text-2xl font-bold font-mono"
>
{value ?? "—"}
</span>
)}
{!loading && delta && (
<span
data-sensitive
className={cn(
"text-xs font-mono font-medium",
delta.positive ? "text-primary" : "text-destructive",
)}
>
{delta.text}
</span>
)}
</div>
{children}
</CardContent>
</Card>
);
const wrapped = to ? (
<Link
to={to}
aria-label={ariaLabel ?? label}
className="block h-full rounded-xl focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
{card}
</Link>
) : (
card
);
if (!hoverContent) return wrapped;
return (
<HoverCard>
<HoverCardTrigger render={<div className="h-full" />}>
{wrapped}
</HoverCardTrigger>
<HoverCardContent className="w-80">{hoverContent}</HoverCardContent>
</HoverCard>
);
}