(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 && (
);
},
@@ -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 && (
+ onConvertTasaCero(tx)}
+ className={cn(tx.is_installment_anchor && 'text-violet-600')}
+ >
+
+
+ )}
+ {onToggleDeferred && tx.installment_plan_id == null && (
('detail');
const [txSearch, setTxSearch] = useState('');
const [txSource, setTxSource] = useState<'CREDIT_CARD' | 'CASH_AND_TRANSFER'>('CREDIT_CARD');
+ const [convertTx, setConvertTx] = useState(null);
const txQuery = useQuery({
queryKey: ['transactions', 'budget', year, selectedMonth, txSource, txSearch],
@@ -79,6 +81,7 @@ export default function Budget() {
const invalidateTransactionsAndBudget = useCallback(() => {
queryClient.invalidateQueries({ queryKey: ['transactions'] });
+ queryClient.invalidateQueries({ queryKey: ['installment-plans'] });
refresh();
}, [queryClient, refresh]);
@@ -209,6 +212,7 @@ export default function Budget() {
showSourceIcon={txSource === 'CASH_AND_TRANSFER'}
emptyMessage={`Sin transacciones de ${txSource === 'CREDIT_CARD' ? 'tarjeta' : 'efectivo o transferencia'} en ${MONTH_NAMES[selectedMonth]}`}
onToggleDeferred={txSource === 'CREDIT_CARD' ? handleToggleDeferred : undefined}
+ onConvertTasaCero={txSource === 'CREDIT_CARD' ? setConvertTx : undefined}
/>
)}
@@ -224,6 +228,13 @@ export default function Budget() {
/>
+
+ !open && setConvertTx(null)}
+ onSaved={invalidateTransactionsAndBudget}
+ tx={convertTx}
+ />
);
}
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}
+ />
+
+ );
+}