Add Asistente chat page with A2UI render tools

Wires CopilotKit v2 chat into the SPA as the Asistente page,
declares a render_spending_summary action backed by a custom
SpendingSummaryCard, and configures static suggestions shown
before the first message.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Carlos Escalante
2026-04-29 22:02:12 -06:00
parent 140a75f706
commit 5d5727ec4e
3 changed files with 575 additions and 0 deletions

View File

@@ -0,0 +1,39 @@
"use client";
import { CopilotChat } from "@copilotkit/react-ui";
import { Sparkles } from "lucide-react";
const SUGGESTIONS = [
{ title: "¿Cuál es mi saldo neto hoy?", message: "¿Cuál es mi saldo neto hoy?" },
{ title: "¿Cuánto gasté en el ciclo anterior?", message: "¿Cuánto gasté en el ciclo anterior?" },
{ title: "Últimas 10 transacciones", message: "Muéstrame mis últimas 10 transacciones" },
{ title: "¿Cómo va mi pensión?", message: "¿Cómo va mi pensión?" },
];
export default function AgentHomeClient() {
return (
<div className="flex flex-col h-[calc(100vh-105px)]">
<div className="mb-4">
<h1 className="text-2xl font-bold tracking-tight font-heading flex items-center gap-2">
<Sparkles className="w-5 h-5 text-primary" />
Asistente
</h1>
<p className="text-sm text-muted-foreground">
Pregúntale a WealthySmart sobre tus finanzas.
</p>
</div>
<div className="flex-1 min-h-0 rounded-xl border border-border overflow-hidden bg-card">
<CopilotChat
className="h-full"
labels={{
title: "WealthySmart",
initial: "¿Qué quieres saber sobre tus finanzas?",
placeholder: "Escribe tu pregunta…",
}}
suggestions={SUGGESTIONS}
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,456 @@
import { TrendingDown, TrendingUp, Wallet, ArrowRightLeft } from "lucide-react";
// ── helpers ──────────────────────────────────────────────────────────────────
function fmtCRC(n: number | undefined | null) {
if (n == null) return "₡0";
return `${Math.round(n).toLocaleString("es-CR")}`;
}
function fmtCurrency(amount: number | undefined | null, currency: string | undefined | null) {
if (amount == null) return "₡0";
if (currency === "USD") return `$${amount.toFixed(2)}`;
if (currency === "EUR") return `${amount.toFixed(2)}`;
return fmtCRC(amount);
}
function sourceLabel(source: string | undefined | null) {
if (!source) return "Otro";
const map: Record<string, string> = {
CREDIT_CARD: "Tarjeta",
CASH: "Efectivo",
TRANSFER: "Transferencia",
SINPE: "SINPE",
OTHER: "Otro",
};
return map[source] ?? source.replace(/_/g, " ");
}
// ── Spinner ───────────────────────────────────────────────────────────────────
function Spinner() {
return (
<div className="w-3 h-3 border-2 border-primary border-t-transparent rounded-full animate-spin inline-block" />
);
}
// ── SpendingSummaryCard ───────────────────────────────────────────────────────
export interface SpendingBySource {
source: string;
total_crc: number;
count: number;
}
export interface SpendingByCategory {
category: string;
amount_crc: number;
count: number;
}
export interface SpendingSummaryArgs {
title?: string;
period?: string;
total_crc?: number;
by_source?: SpendingBySource[];
by_category?: SpendingByCategory[];
}
export function SpendingSummaryCard({
args,
status,
}: {
args: SpendingSummaryArgs;
status: string;
}) {
const { title, period, total_crc, by_source = [], by_category = [] } = args;
const max = Math.max(...by_category.map((c) => c.amount_crc), 1);
const PALETTE = [
"bg-primary",
"bg-chart-1",
"bg-chart-2",
"bg-chart-3",
"bg-chart-4",
"bg-chart-5",
];
return (
<div className="rounded-xl border border-border bg-card text-card-foreground p-4 space-y-4 my-2 shadow-sm">
{/* Header */}
<div className="flex items-start justify-between gap-4">
<div>
<p className="text-[11px] font-medium uppercase tracking-widest text-muted-foreground">
{title ?? "Resumen de gastos"}
</p>
{period && (
<p className="text-xs text-muted-foreground mt-0.5">{period}</p>
)}
</div>
<div className="text-right shrink-0">
<p className="text-2xl font-bold tabular-nums">{fmtCRC(total_crc)}</p>
<p className="text-[11px] text-muted-foreground">total gastado</p>
</div>
</div>
{/* By source */}
{by_source.length > 0 && (
<div className="grid grid-cols-2 gap-2">
{by_source.filter((s) => s?.source != null).map((s) => (
<div
key={s.source}
className="rounded-lg bg-secondary/40 border border-border/50 px-3 py-2"
>
<p className="text-[11px] text-muted-foreground">
{sourceLabel(s.source)}
</p>
<p className="font-semibold text-sm tabular-nums">
{fmtCRC(s.total_crc)}
</p>
<p className="text-[11px] text-muted-foreground">
{s.count} mov.
</p>
</div>
))}
</div>
)}
{/* By category */}
{by_category.length > 0 && (
<div>
<p className="text-[11px] font-medium uppercase tracking-widest text-muted-foreground mb-2">
Por categoría
</p>
<div className="space-y-2">
{by_category.filter((c) => c?.category != null).slice(0, 7).map((c, i) => (
<div key={c.category}>
<div className="flex justify-between text-xs mb-1">
<span className="text-card-foreground">{c.category}</span>
<span className="text-muted-foreground tabular-nums">
{fmtCRC(c.amount_crc)}
<span className="text-[10px] ml-1">({c.count})</span>
</span>
</div>
<div className="h-1.5 bg-secondary rounded-full overflow-hidden">
<div
className={`h-full rounded-full transition-all ${PALETTE[i % PALETTE.length]}`}
style={{
width: `${Math.round(((c.amount_crc ?? 0) / max) * 100)}%`,
}}
/>
</div>
</div>
))}
</div>
</div>
)}
{status === "inProgress" && (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Spinner /> Obteniendo datos
</div>
)}
</div>
);
}
// ── TransactionListCard ───────────────────────────────────────────────────────
export interface TransactionRow {
date: string;
merchant: string;
amount: number;
currency: string;
category: string | null;
source: string;
}
export interface TransactionListArgs {
title?: string;
transactions?: TransactionRow[];
}
const SOURCE_ICON: Record<string, React.ReactNode> = {
CREDIT_CARD: <Wallet className="w-3 h-3" />,
TRANSFER: <ArrowRightLeft className="w-3 h-3" />,
};
export function TransactionListCard({
args,
status,
}: {
args: TransactionListArgs;
status: string;
}) {
const { title, transactions = [] } = args;
return (
<div className="rounded-xl border border-border bg-card text-card-foreground p-4 my-2 shadow-sm">
<p className="text-[11px] font-medium uppercase tracking-widest text-muted-foreground mb-3">
{title ?? "Transacciones"}
</p>
{transactions.length === 0 && status !== "inProgress" && (
<p className="text-sm text-muted-foreground">Sin transacciones.</p>
)}
<div className="divide-y divide-border/50">
{transactions.map((t, i) => (
<div key={i} className="flex items-center justify-between py-2 gap-3">
<div className="flex items-center gap-2 min-w-0">
<div className="shrink-0 w-6 h-6 rounded-full bg-secondary flex items-center justify-center text-muted-foreground">
{SOURCE_ICON[t.source] ?? <Wallet className="w-3 h-3" />}
</div>
<div className="min-w-0">
<p className="text-sm font-medium truncate">{t.merchant}</p>
<p className="text-[11px] text-muted-foreground">
{t.date}
{t.category && ` · ${t.category}`}
</p>
</div>
</div>
<p className="text-sm font-semibold tabular-nums shrink-0 text-destructive">
{fmtCurrency(t.amount, t.currency)}
</p>
</div>
))}
</div>
{status === "inProgress" && (
<div className="flex items-center gap-2 text-xs text-muted-foreground mt-2">
<Spinner /> Cargando
</div>
)}
</div>
);
}
// ── NetWorthCard ──────────────────────────────────────────────────────────────
export interface AccountRow {
bank: string;
label: string;
balance_crc: number;
account_type: string;
currency: string;
}
export interface NetWorthArgs {
total_assets_crc?: number;
total_liabilities_crc?: number;
net_worth_crc?: number;
accounts?: AccountRow[];
}
export function NetWorthCard({
args,
status,
}: {
args: NetWorthArgs;
status: string;
}) {
const {
total_assets_crc = 0,
total_liabilities_crc = 0,
net_worth_crc = 0,
accounts = [],
} = args;
const isPositive = net_worth_crc >= 0;
return (
<div className="rounded-xl border border-border bg-card text-card-foreground p-4 space-y-4 my-2 shadow-sm">
{/* Net worth headline */}
<div className="flex items-start justify-between gap-4">
<div>
<p className="text-[11px] font-medium uppercase tracking-widest text-muted-foreground">
Patrimonio neto
</p>
<div className="flex items-center gap-1 mt-0.5">
{isPositive ? (
<TrendingUp className="w-4 h-4 text-green-500" />
) : (
<TrendingDown className="w-4 h-4 text-destructive" />
)}
<span
className={`text-2xl font-bold tabular-nums ${isPositive ? "text-green-500" : "text-destructive"}`}
>
{fmtCRC(net_worth_crc)}
</span>
</div>
</div>
<div className="text-right text-xs space-y-1">
<p>
<span className="text-muted-foreground">Activos </span>
<span className="font-semibold text-green-500">
{fmtCRC(total_assets_crc)}
</span>
</p>
<p>
<span className="text-muted-foreground">Pasivos </span>
<span className="font-semibold text-destructive">
{fmtCRC(total_liabilities_crc)}
</span>
</p>
</div>
</div>
{/* Asset bar */}
{total_assets_crc + total_liabilities_crc > 0 && (
<div className="h-2 bg-secondary rounded-full overflow-hidden flex">
<div
className="h-full bg-green-500 transition-all"
style={{
width: `${Math.round(
(total_assets_crc /
(total_assets_crc + total_liabilities_crc)) *
100,
)}%`,
}}
/>
<div className="h-full bg-destructive flex-1" />
</div>
)}
{/* Accounts */}
{accounts.length > 0 && (
<div className="divide-y divide-border/50">
{accounts.map((a, i) => (
<div key={i} className="flex items-center justify-between py-2">
<div>
<p className="text-sm font-medium">{a.label || a.bank}</p>
<p className="text-[11px] text-muted-foreground">
{a.bank} · {a.account_type} · {a.currency}
</p>
</div>
<p
className={`text-sm font-semibold tabular-nums ${a.balance_crc >= 0 ? "text-green-500" : "text-destructive"}`}
>
{fmtCRC(a.balance_crc)}
</p>
</div>
))}
</div>
)}
{status === "inProgress" && (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Spinner /> Calculando
</div>
)}
</div>
);
}
// ── BudgetMonthCard ───────────────────────────────────────────────────────────
const MONTH_NAMES = [
"", "Enero", "Febrero", "Marzo", "Abril", "Mayo", "Junio",
"Julio", "Agosto", "Septiembre", "Octubre", "Noviembre", "Diciembre",
];
export interface BudgetMonthArgs {
year?: number;
month?: number;
projected_income_crc?: number;
projected_expenses_crc?: number;
actual_total_crc?: number;
net_balance_crc?: number;
}
export function BudgetMonthCard({
args,
status,
}: {
args: BudgetMonthArgs;
status: string;
}) {
const {
year,
month,
projected_income_crc = 0,
projected_expenses_crc = 0,
actual_total_crc = 0,
net_balance_crc = 0,
} = args;
const usedPct =
projected_expenses_crc > 0
? Math.min(Math.round((actual_total_crc / projected_expenses_crc) * 100), 100)
: 0;
const isOver = actual_total_crc > projected_expenses_crc;
return (
<div className="rounded-xl border border-border bg-card text-card-foreground p-4 space-y-4 my-2 shadow-sm">
<div className="flex items-start justify-between gap-4">
<div>
<p className="text-[11px] font-medium uppercase tracking-widest text-muted-foreground">
Presupuesto
</p>
{month != null && year != null && (
<p className="text-sm font-semibold mt-0.5">
{MONTH_NAMES[month]} {year}
</p>
)}
</div>
<div
className={`text-right text-2xl font-bold tabular-nums ${net_balance_crc >= 0 ? "text-green-500" : "text-destructive"}`}
>
{fmtCRC(net_balance_crc)}
<p className="text-[11px] font-normal text-muted-foreground">
balance neto
</p>
</div>
</div>
{/* Rows */}
<div className="space-y-1.5 text-sm">
<div className="flex justify-between">
<span className="text-muted-foreground">Ingresos proyectados</span>
<span className="font-medium tabular-nums text-green-500">
{fmtCRC(projected_income_crc)}
</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Gastos proyectados</span>
<span className="font-medium tabular-nums">
{fmtCRC(projected_expenses_crc)}
</span>
</div>
<div className="flex justify-between border-t border-border/50 pt-1.5">
<span className="text-muted-foreground">Gastado real</span>
<span
className={`font-semibold tabular-nums ${isOver ? "text-destructive" : ""}`}
>
{fmtCRC(actual_total_crc)}
</span>
</div>
</div>
{/* Progress bar */}
{projected_expenses_crc > 0 && (
<div>
<div className="flex justify-between text-[11px] text-muted-foreground mb-1">
<span>Ejecución presupuestaria</span>
<span className={isOver ? "text-destructive font-semibold" : ""}>
{usedPct}%
</span>
</div>
<div className="h-2 bg-secondary rounded-full overflow-hidden">
<div
className={`h-full rounded-full transition-all ${isOver ? "bg-destructive" : "bg-primary"}`}
style={{ width: `${usedPct}%` }}
/>
</div>
</div>
)}
{status === "inProgress" && (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Spinner /> Cargando
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,80 @@
import { CopilotChat, useConfigureSuggestions } from "@copilotkit/react-core/v2";
import { useCopilotAction } from "@copilotkit/react-core";
import { Sparkles } from "lucide-react";
import { SpendingSummaryCard, type SpendingSummaryArgs } from "@/components/chat/ChatCards";
const STATIC_SUGGESTIONS = {
available: "before-first-message" as const,
suggestions: [
{ title: "¿Cuál es mi saldo neto hoy?", message: "¿Cuál es mi saldo neto hoy?" },
{ title: "¿Cuánto gasté en el ciclo anterior?", message: "¿Cuánto gasté en el ciclo anterior?" },
{ title: "Últimas 10 transacciones", message: "Muéstrame mis últimas 10 transacciones" },
{ title: "¿Cómo va mi pensión?", message: "¿Cómo va mi pensión?" },
],
};
export default function Asistente() {
useConfigureSuggestions(STATIC_SUGGESTIONS);
useCopilotAction({
name: "render_spending_summary",
description:
"Render a visual spending summary card with source breakdown and category progress bars. " +
"Call this for any cycle summary, spending totals, or category breakdown.",
parameters: [
{ name: "title", type: "string", description: "Card title (e.g. 'Ciclo actual')" },
{ name: "period", type: "string", description: "Human-readable period (e.g. '18 mar → 18 abr 2026')" },
{ name: "total_crc", type: "number", description: "Total spend in CRC" },
{
name: "by_source",
type: "object[]",
description: "Breakdown by payment source",
attributes: [
{ name: "source", type: "string" },
{ name: "total_crc", type: "number" },
{ name: "count", type: "number" },
],
},
{
name: "by_category",
type: "object[]",
required: false,
description: "Top spending categories with CRC amounts",
attributes: [
{ name: "category", type: "string" },
{ name: "amount_crc", type: "number" },
{ name: "count", type: "number" },
],
},
],
handler: async () => "ok",
render: (props) => (
<SpendingSummaryCard args={props.args as SpendingSummaryArgs} status={props.status} />
),
});
return (
<div className="flex flex-col h-[calc(100vh-105px)]">
<div className="mb-4">
<h1 className="text-2xl font-bold tracking-tight flex items-center gap-2" style={{ fontFamily: "var(--font-heading)" }}>
<Sparkles className="w-5 h-5 text-primary" />
Asistente
</h1>
<p className="text-sm text-muted-foreground">
Pregúntale a WealthySmart sobre tus finanzas.
</p>
</div>
<div className="flex-1 min-h-0 rounded-xl border border-border overflow-hidden bg-card">
<CopilotChat
className="h-full"
labels={{
modalHeaderTitle: "WealthySmart",
welcomeMessageText: "¿Qué quieres saber sobre tus finanzas?",
chatInputPlaceholder: "Escribe tu pregunta…",
}}
/>
</div>
</div>
);
}