From 5d5727ec4e9ee8c117b04694cb72a64fb365c2f7 Mon Sep 17 00:00:00 2001 From: Carlos Escalante Date: Wed, 29 Apr 2026 22:02:12 -0600 Subject: [PATCH] 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) --- frontend/src/components/AgentHomeClient.tsx | 39 ++ frontend/src/components/chat/ChatCards.tsx | 456 ++++++++++++++++++++ frontend/src/pages/Asistente.tsx | 80 ++++ 3 files changed, 575 insertions(+) create mode 100644 frontend/src/components/AgentHomeClient.tsx create mode 100644 frontend/src/components/chat/ChatCards.tsx create mode 100644 frontend/src/pages/Asistente.tsx diff --git a/frontend/src/components/AgentHomeClient.tsx b/frontend/src/components/AgentHomeClient.tsx new file mode 100644 index 0000000..69a249b --- /dev/null +++ b/frontend/src/components/AgentHomeClient.tsx @@ -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 ( +
+
+

+ + Asistente +

+

+ Pregúntale a WealthySmart sobre tus finanzas. +

+
+ +
+ +
+
+ ); +} diff --git a/frontend/src/components/chat/ChatCards.tsx b/frontend/src/components/chat/ChatCards.tsx new file mode 100644 index 0000000..d52fdf5 --- /dev/null +++ b/frontend/src/components/chat/ChatCards.tsx @@ -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 = { + CREDIT_CARD: "Tarjeta", + CASH: "Efectivo", + TRANSFER: "Transferencia", + SINPE: "SINPE", + OTHER: "Otro", + }; + return map[source] ?? source.replace(/_/g, " "); +} + +// ── Spinner ─────────────────────────────────────────────────────────────────── + +function Spinner() { + return ( +
+ ); +} + +// ── 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 ( +
+ {/* Header */} +
+
+

+ {title ?? "Resumen de gastos"} +

+ {period && ( +

{period}

+ )} +
+
+

{fmtCRC(total_crc)}

+

total gastado

+
+
+ + {/* By source */} + {by_source.length > 0 && ( +
+ {by_source.filter((s) => s?.source != null).map((s) => ( +
+

+ {sourceLabel(s.source)} +

+

+ {fmtCRC(s.total_crc)} +

+

+ {s.count} mov. +

+
+ ))} +
+ )} + + {/* By category */} + {by_category.length > 0 && ( +
+

+ Por categoría +

+
+ {by_category.filter((c) => c?.category != null).slice(0, 7).map((c, i) => ( +
+
+ {c.category} + + {fmtCRC(c.amount_crc)} + ({c.count}) + +
+
+
+
+
+ ))} +
+
+ )} + + {status === "inProgress" && ( +
+ Obteniendo datos… +
+ )} +
+ ); +} + +// ── 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 = { + CREDIT_CARD: , + TRANSFER: , +}; + +export function TransactionListCard({ + args, + status, +}: { + args: TransactionListArgs; + status: string; +}) { + const { title, transactions = [] } = args; + + return ( +
+

+ {title ?? "Transacciones"} +

+ + {transactions.length === 0 && status !== "inProgress" && ( +

Sin transacciones.

+ )} + +
+ {transactions.map((t, i) => ( +
+
+
+ {SOURCE_ICON[t.source] ?? } +
+
+

{t.merchant}

+

+ {t.date} + {t.category && ` · ${t.category}`} +

+
+
+

+ {fmtCurrency(t.amount, t.currency)} +

+
+ ))} +
+ + {status === "inProgress" && ( +
+ Cargando… +
+ )} +
+ ); +} + +// ── 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 ( +
+ {/* Net worth headline */} +
+
+

+ Patrimonio neto +

+
+ {isPositive ? ( + + ) : ( + + )} + + {fmtCRC(net_worth_crc)} + +
+
+
+

+ Activos + + {fmtCRC(total_assets_crc)} + +

+

+ Pasivos + + {fmtCRC(total_liabilities_crc)} + +

+
+
+ + {/* Asset bar */} + {total_assets_crc + total_liabilities_crc > 0 && ( +
+
+
+
+ )} + + {/* Accounts */} + {accounts.length > 0 && ( +
+ {accounts.map((a, i) => ( +
+
+

{a.label || a.bank}

+

+ {a.bank} · {a.account_type} · {a.currency} +

+
+

= 0 ? "text-green-500" : "text-destructive"}`} + > + {fmtCRC(a.balance_crc)} +

+
+ ))} +
+ )} + + {status === "inProgress" && ( +
+ Calculando… +
+ )} +
+ ); +} + +// ── 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 ( +
+
+
+

+ Presupuesto +

+ {month != null && year != null && ( +

+ {MONTH_NAMES[month]} {year} +

+ )} +
+
= 0 ? "text-green-500" : "text-destructive"}`} + > + {fmtCRC(net_balance_crc)} +

+ balance neto +

+
+
+ + {/* Rows */} +
+
+ Ingresos proyectados + + {fmtCRC(projected_income_crc)} + +
+
+ Gastos proyectados + + {fmtCRC(projected_expenses_crc)} + +
+
+ Gastado real + + {fmtCRC(actual_total_crc)} + +
+
+ + {/* Progress bar */} + {projected_expenses_crc > 0 && ( +
+
+ Ejecución presupuestaria + + {usedPct}% + +
+
+
+
+
+ )} + + {status === "inProgress" && ( +
+ Cargando… +
+ )} +
+ ); +} diff --git a/frontend/src/pages/Asistente.tsx b/frontend/src/pages/Asistente.tsx new file mode 100644 index 0000000..1bebb47 --- /dev/null +++ b/frontend/src/pages/Asistente.tsx @@ -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) => ( + + ), + }); + + return ( +
+
+

+ + Asistente +

+

+ Pregúntale a WealthySmart sobre tus finanzas. +

+
+ +
+ +
+
+ ); +}