From d3b5188b67aaefa162919d92f9cfcefd908c6fe3 Mon Sep 17 00:00:00 2001 From: Carlos Escalante Date: Fri, 3 Jul 2026 16:13:18 -0600 Subject: [PATCH] Tasa Cero: badges, convert dialog, Financiamientos page Violet 'Tasa Cero'/'Cuota' badges (anchor amount struck through like deferred), convert/edit action on CC purchases with a live cuota preview, and a Financiamientos page mirroring the bank's tab (progress, monto cuota, saldo inicial/faltante, totals). Co-Authored-By: Claude Opus 4.8 (1M context) --- frontend/src/App.tsx | 2 + frontend/src/components/Layout.tsx | 2 + frontend/src/components/TransactionList.tsx | 31 +- .../ConvertToInstallmentsDialog.tsx | 278 ++++++++++++++++++ .../transactions/transaction-columns.tsx | 33 ++- frontend/src/pages/Budget.tsx | 11 + frontend/src/pages/Financiamientos.tsx | 207 +++++++++++++ 7 files changed, 558 insertions(+), 6 deletions(-) create mode 100644 frontend/src/components/transactions/ConvertToInstallmentsDialog.tsx create mode 100644 frontend/src/pages/Financiamientos.tsx diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index bdb92fe..b04baab 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -20,6 +20,7 @@ import Asistente from "./pages/Asistente"; import Analytics from "./pages/Analytics"; import Budget from "./pages/Budget"; import Salarios from "./pages/Salarios"; +import Financiamientos from "./pages/Financiamientos"; import Pensions from "./pages/Pensions"; import Proyecciones from "./pages/Proyecciones"; import ServiciosMunicipales from "./pages/ServiciosMunicipales"; @@ -56,6 +57,7 @@ function AppRoutes() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index 0175beb..7b86120 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -8,6 +8,7 @@ import { Landmark, PiggyBank, Droplets, + Layers, LogOut, TrendingUp, Wallet, @@ -54,6 +55,7 @@ const navSections: NavSection[] = [ items: [ { to: "/budget", icon: Calculator, label: "Presupuesto" }, { to: "/salarios", icon: Landmark, label: "Salarios" }, + { to: "/financiamientos", icon: Layers, label: "Financiamientos" }, { to: "/pensions", icon: PiggyBank, label: "Pensiones" }, { to: "/proyecciones", icon: TrendingUp, label: "Proyecciones" }, { to: "/planificador", icon: Telescope, label: "Planificador" }, diff --git a/frontend/src/components/TransactionList.tsx b/frontend/src/components/TransactionList.tsx index 8000098..968e110 100644 --- a/frontend/src/components/TransactionList.tsx +++ b/frontend/src/components/TransactionList.tsx @@ -9,6 +9,7 @@ import { ArrowLeftRight, ArrowRightFromLine, Banknote, + Layers, } from 'lucide-react'; import { toast } from 'sonner'; @@ -46,6 +47,7 @@ export interface TransactionListProps { showSourceIcon?: boolean; addLabel?: string; onToggleDeferred?: (tx: Transaction) => void; + onConvertTasaCero?: (tx: Transaction) => void; } export default function TransactionList({ @@ -61,6 +63,7 @@ export default function TransactionList({ showSourceIcon = false, addLabel = 'Add Transaction', onToggleDeferred, + onConvertTasaCero, }: TransactionListProps) { const [modalOpen, setModalOpen] = useState(false); const [editing, setEditing] = useState(null); @@ -165,6 +168,7 @@ export default function TransactionList({ onEdit: handleEdit, onDelete: (id) => setDeleteId(id), onToggleDeferred, + onConvertTasaCero, selection: { selected, allSelected, @@ -182,7 +186,7 @@ export default function TransactionList({ }, }), // eslint-disable-next-line react-hooks/exhaustive-deps - [showCategory, showSourceIcon, onToggleDeferred, selected, allSelected, transactions], + [showCategory, showSourceIcon, onToggleDeferred, onConvertTasaCero, selected, allSelected, transactions], ); const visibleTransactions = pendingDeletes.size @@ -331,6 +335,11 @@ export default function TransactionList({ ? : null )} + {tx.is_installment_anchor && ( + + Tasa Cero + + )}

{new Date(tx.date).toLocaleDateString('es-CR', { month: 'short', day: 'numeric' })} @@ -343,14 +352,30 @@ export default function TransactionList({ data-sensitive className={cn( 'font-mono text-sm font-medium shrink-0', - tx.transaction_type === 'DEVOLUCION' && 'text-primary' + tx.transaction_type === 'DEVOLUCION' && 'text-primary', + (tx.deferred_to_next_cycle || tx.is_installment_anchor) && + 'opacity-50 line-through decoration-muted-foreground/60', )} > {tx.transaction_type === 'DEVOLUCION' ? '+' : '-'} {formatAmount(tx.amount, tx.currency)}

- {onToggleDeferred && ( + {onConvertTasaCero && + tx.transaction_type === 'COMPRA' && + tx.installment_plan_id == null && ( + + )} + {onToggleDeferred && tx.installment_plan_id == null && ( + ) : ( + + )} +
+ + +
+ + + + ); +} diff --git a/frontend/src/components/transactions/transaction-columns.tsx b/frontend/src/components/transactions/transaction-columns.tsx index 1e04a7c..c12577b 100644 --- a/frontend/src/components/transactions/transaction-columns.tsx +++ b/frontend/src/components/transactions/transaction-columns.tsx @@ -1,5 +1,5 @@ import { type ColumnDef } from '@tanstack/react-table'; -import { ArrowLeftRight, ArrowRightFromLine, Banknote, Pencil, Trash2, TrendingDown, TrendingUp } from 'lucide-react'; +import { ArrowLeftRight, ArrowRightFromLine, Banknote, Layers, Pencil, Trash2, TrendingDown, TrendingUp } from 'lucide-react'; import { type Transaction } from '@/lib/api'; import { formatAmount } from '@/lib/format'; @@ -21,6 +21,7 @@ interface TransactionColumnOptions { onEdit: (tx: Transaction) => void; onDelete: (txId: number) => void; onToggleDeferred?: (tx: Transaction) => void; + onConvertTasaCero?: (tx: Transaction) => void; selection?: RowSelection; } @@ -30,6 +31,7 @@ export function getTransactionColumns({ onEdit, onDelete, onToggleDeferred, + onConvertTasaCero, selection, }: TransactionColumnOptions): ColumnDef[] { const columns: ColumnDef[] = []; @@ -107,6 +109,16 @@ export function getTransactionColumns({ Diferida )} + {tx.is_installment_anchor && ( + + Tasa Cero + + )} + {tx.installment_plan_id != null && ( + + Cuota + + )}
); }, @@ -144,7 +156,8 @@ export function getTransactionColumns({ className={cn( 'font-mono font-medium', tx.transaction_type !== 'COMPRA' && 'text-primary', - tx.deferred_to_next_cycle && 'opacity-50 line-through decoration-muted-foreground/60', + (tx.deferred_to_next_cycle || tx.is_installment_anchor) && + 'opacity-50 line-through decoration-muted-foreground/60', )} > {tx.transaction_type === 'COMPRA' ? '-' : '+'} @@ -162,7 +175,21 @@ export function getTransactionColumns({ const tx = row.original; return (
- {onToggleDeferred && ( + {onConvertTasaCero && + tx.transaction_type === 'COMPRA' && + tx.installment_plan_id == null && ( + + )} + {onToggleDeferred && tx.installment_plan_id == null && (
); } diff --git a/frontend/src/pages/Financiamientos.tsx b/frontend/src/pages/Financiamientos.tsx new file mode 100644 index 0000000..8dd7b38 --- /dev/null +++ b/frontend/src/pages/Financiamientos.tsx @@ -0,0 +1,207 @@ +import { useState } from 'react'; +import { Layers } from 'lucide-react'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; + +import { + getInstallmentPlans, + type InstallmentPlan, +} from '@/lib/api'; +import { formatAmount } from '@/lib/format'; +import { cn } from '@/lib/utils'; +import { Badge } from '@/components/ui/badge'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table'; +import ErrorState from '@/components/ErrorState'; +import ConvertToInstallmentsDialog from '@/components/transactions/ConvertToInstallmentsDialog'; + +function formatDate(iso: string) { + return new Date(iso).toLocaleDateString('es-CR', { + day: 'numeric', + month: 'short', + year: 'numeric', + }); +} + +function progressLabel(plan: InstallmentPlan) { + return `${String(plan.cuotas_billed).padStart(3, '0')}/${String(plan.num_installments).padStart(3, '0')}`; +} + +export default function Financiamientos() { + const queryClient = useQueryClient(); + const [editing, setEditing] = useState(null); + + const plansQ = useQuery({ + queryKey: ['installment-plans'], + queryFn: () => getInstallmentPlans().then((r) => r.data), + }); + + const plans = plansQ.data?.plans ?? []; + const active = plans.filter((p) => !p.is_completed); + const completed = plans.filter((p) => p.is_completed); + const totalRemaining = plansQ.data?.total_remaining ?? 0; + const totalCuotaMes = active.reduce((sum, p) => sum + p.installment_amount, 0); + + const handleSaved = () => { + queryClient.invalidateQueries({ queryKey: ['installment-plans'] }); + queryClient.invalidateQueries({ queryKey: ['transactions'] }); + queryClient.invalidateQueries({ queryKey: ['budget'] }); + }; + + const renderRows = (rows: InstallmentPlan[]) => + rows.map((plan) => ( + setEditing(plan)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + setEditing(plan); + } + }} + > + +
+ {plan.merchant} + {plan.is_completed && ( + + Completado + + )} +
+
+ + {formatDate(plan.purchase_date)} + + {progressLabel(plan)} + + {formatAmount(plan.installment_amount, plan.currency)} + + + {formatAmount(plan.total_amount, plan.currency)} + + + {formatAmount(plan.remaining_amount, plan.currency)} + + + {plan.next_cuota_date ? formatDate(plan.next_cuota_date) : '—'} + +
+ )); + + return ( +
+
+ +

Financiamientos

+
+

+ Compras Tasa Cero pagadas en cuotas mensuales sin intereses. Las cuotas + futuras ya cuentan en el presupuesto de sus meses. +

+ + {/* Summary */} +
+ + + + Saldo faltante total + + + + + {formatAmount(totalRemaining, 'CRC')} + + + + + + + Cuotas por mes (activos) + + + + + {formatAmount(totalCuotaMes, 'CRC')} + + + + + + + Planes activos + + + + {active.length} + {completed.length > 0 && ( + + (+{completed.length} completado{completed.length === 1 ? '' : 's'}) + + )} + + +
+ + {/* Plans table */} + {plansQ.isError ? ( + plansQ.refetch()} + /> + ) : ( + + + {plans.length === 0 && !plansQ.isPending ? ( +
+ +

+ Sin financiamientos. Convertí una compra de tarjeta a Tasa + Cero desde la lista de transacciones. +

+
+ ) : ( + + + + Comercio + Fecha compra + Cuotas + Monto cuota + Saldo inicial + Saldo faltante + Próxima cuota + + + + {renderRows(active)} + {renderRows(completed)} + +
+ )} +
+
+ )} + + !open && setEditing(null)} + onSaved={handleSaved} + plan={editing} + /> +
+ ); +}