Files
WealthySmart/frontend/src/pages/Inicio.tsx
Carlos Escalante a95486481b 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>
2026-07-04 12:23:21 -06:00

279 lines
8.5 KiB
TypeScript

import { useState } from "react";
import { Link, useNavigate } from "react-router-dom";
import { ArrowRight, Home, Sparkles } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import {
getInstallmentPlans,
getMonthlyDetail,
getPensionFundSummary,
getRecentTransactions,
} from "@/lib/api";
import { formatAmount } from "@/lib/format";
import {
MONTH_NAMES_ES_SHORT,
currentBudgetCycle,
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, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Skeleton } from "@/components/ui/skeleton";
const CYCLE = currentBudgetCycle();
function GastoDelCicloCard() {
const q = useQuery({
queryKey: ["budget", "month", CYCLE.year, CYCLE.month],
queryFn: () =>
getMonthlyDetail(CYCLE.year, CYCLE.month).then((r) => r.data),
});
const spend = q.data
? q.data.actuals_by_source.reduce((sum, a) => sum + a.net, 0)
: null;
const balance = q.data?.net_balance ?? null;
return (
<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>
);
}
function ProximasCuotasCard() {
const q = useQuery({
queryKey: ["installment-plans"],
queryFn: () => getInstallmentPlans().then((r) => r.data),
});
const active = (q.data?.plans ?? []).filter(
(p) => !p.is_completed && p.next_cuota_date,
);
// One cuota per plan per cycle, so the sum of every active plan's next
// cuota is the load of the upcoming billing batch (next 19th). The last
// cuota absorbs BAC's rounding, so use it when it's the one pending.
const nextCuotaOf = (p: (typeof active)[number]) =>
p.cuotas_billed === p.num_installments - 1
? p.last_installment_amount
: p.installment_amount;
const nextBatchTotal = active.reduce((s, p) => s + nextCuotaOf(p), 0);
const upcoming = [...active].sort(
(a, b) =>
new Date(a.next_cuota_date!).getTime() -
new Date(b.next_cuota_date!).getTime(),
);
const nextDate = upcoming[0]?.next_cuota_date;
const topThree = upcoming.slice(0, 3);
return (
<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>
);
}
function PensionCard() {
const q = useQuery({
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;
return (
<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>
);
}
function AskAssistantCard() {
const [question, setQuestion] = useState("");
const navigate = useNavigate();
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const ask = question.trim();
navigate("/asistente", ask ? { state: { ask } } : undefined);
};
return (
<Card>
<CardContent className="pt-6">
<form onSubmit={handleSubmit} className="flex items-center gap-3">
<Sparkles className="w-5 h-5 text-primary shrink-0" aria-hidden />
<Input
value={question}
onChange={(e) => setQuestion(e.target.value)}
placeholder="¿Cuánto gasté este ciclo?"
aria-label="Pregúntale al asistente"
className="flex-1"
/>
<Button type="submit">Preguntar</Button>
</form>
</CardContent>
</Card>
);
}
function RecentTransactionsCard() {
const q = useQuery({
queryKey: ["transactions", "recent"],
queryFn: () => getRecentTransactions(5).then((r) => r.data),
});
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
Últimas transacciones
</CardTitle>
<Link
to="/budget"
className="flex items-center gap-1 text-xs text-primary hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded"
>
Ver todo
<ArrowRight className="w-3 h-3" aria-hidden />
</Link>
</CardHeader>
<CardContent>
{q.isPending ? (
<div className="space-y-2">
{Array.from({ length: 5 }, (_, i) => (
<Skeleton key={i} className="h-6 w-full" />
))}
</div>
) : q.isError ? (
<p className="text-sm text-muted-foreground">
No se pudieron cargar las transacciones.
</p>
) : (
<ul className="divide-y divide-border">
{(q.data ?? []).map((tx) => (
<li
key={tx.id}
className="flex items-center justify-between gap-3 py-2 text-sm"
>
<span className="truncate">{tx.merchant}</span>
<span className="flex items-center gap-3 shrink-0">
<span className="text-xs text-muted-foreground">
{formatShortDate(tx.date)}
</span>
<span
data-sensitive
className={cn(
"font-mono",
tx.transaction_type !== "COMPRA" && "text-primary",
)}
>
{tx.transaction_type === "COMPRA" ? "-" : "+"}
{formatAmount(tx.amount, tx.currency)}
</span>
</span>
</li>
))}
</ul>
)}
</CardContent>
</Card>
);
}
export default function Inicio() {
return (
<div className="space-y-6">
<PageHeader
icon={Home}
title="Inicio"
subtitle="Tu panorama financiero de hoy."
/>
<div className="grid gap-4 md:grid-cols-3">
<GastoDelCicloCard />
<ProximasCuotasCard />
<PensionCard />
</div>
<AskAssistantCard />
<RecentTransactionsCard />
</div>
);
}