Tasa Cero: badges, convert dialog, Financiamientos page
Some checks failed
Deploy to VPS / test (push) Failing after 56m9s
Deploy to VPS / deploy (push) Has been skipped

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) <noreply@anthropic.com>
This commit is contained in:
Carlos Escalante
2026-07-03 16:13:18 -06:00
parent c563618957
commit d3b5188b67
7 changed files with 558 additions and 6 deletions

View File

@@ -20,6 +20,7 @@ import Asistente from "./pages/Asistente";
import Analytics from "./pages/Analytics"; import Analytics from "./pages/Analytics";
import Budget from "./pages/Budget"; import Budget from "./pages/Budget";
import Salarios from "./pages/Salarios"; import Salarios from "./pages/Salarios";
import Financiamientos from "./pages/Financiamientos";
import Pensions from "./pages/Pensions"; import Pensions from "./pages/Pensions";
import Proyecciones from "./pages/Proyecciones"; import Proyecciones from "./pages/Proyecciones";
import ServiciosMunicipales from "./pages/ServiciosMunicipales"; import ServiciosMunicipales from "./pages/ServiciosMunicipales";
@@ -56,6 +57,7 @@ function AppRoutes() {
<Route path="/analytics" element={<Analytics />} /> <Route path="/analytics" element={<Analytics />} />
<Route path="/proyecciones" element={<Proyecciones />} /> <Route path="/proyecciones" element={<Proyecciones />} />
<Route path="/salarios" element={<Salarios />} /> <Route path="/salarios" element={<Salarios />} />
<Route path="/financiamientos" element={<Financiamientos />} />
<Route path="/pensions" element={<Pensions />} /> <Route path="/pensions" element={<Pensions />} />
<Route path="/servicios-municipales" element={<ServiciosMunicipales />} /> <Route path="/servicios-municipales" element={<ServiciosMunicipales />} />
<Route path="/sync" element={<SyncStatus />} /> <Route path="/sync" element={<SyncStatus />} />

View File

@@ -8,6 +8,7 @@ import {
Landmark, Landmark,
PiggyBank, PiggyBank,
Droplets, Droplets,
Layers,
LogOut, LogOut,
TrendingUp, TrendingUp,
Wallet, Wallet,
@@ -54,6 +55,7 @@ const navSections: NavSection[] = [
items: [ items: [
{ to: "/budget", icon: Calculator, label: "Presupuesto" }, { to: "/budget", icon: Calculator, label: "Presupuesto" },
{ to: "/salarios", icon: Landmark, label: "Salarios" }, { to: "/salarios", icon: Landmark, label: "Salarios" },
{ to: "/financiamientos", icon: Layers, label: "Financiamientos" },
{ to: "/pensions", icon: PiggyBank, label: "Pensiones" }, { to: "/pensions", icon: PiggyBank, label: "Pensiones" },
{ to: "/proyecciones", icon: TrendingUp, label: "Proyecciones" }, { to: "/proyecciones", icon: TrendingUp, label: "Proyecciones" },
{ to: "/planificador", icon: Telescope, label: "Planificador" }, { to: "/planificador", icon: Telescope, label: "Planificador" },

View File

@@ -9,6 +9,7 @@ import {
ArrowLeftRight, ArrowLeftRight,
ArrowRightFromLine, ArrowRightFromLine,
Banknote, Banknote,
Layers,
} from 'lucide-react'; } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
@@ -46,6 +47,7 @@ export interface TransactionListProps {
showSourceIcon?: boolean; showSourceIcon?: boolean;
addLabel?: string; addLabel?: string;
onToggleDeferred?: (tx: Transaction) => void; onToggleDeferred?: (tx: Transaction) => void;
onConvertTasaCero?: (tx: Transaction) => void;
} }
export default function TransactionList({ export default function TransactionList({
@@ -61,6 +63,7 @@ export default function TransactionList({
showSourceIcon = false, showSourceIcon = false,
addLabel = 'Add Transaction', addLabel = 'Add Transaction',
onToggleDeferred, onToggleDeferred,
onConvertTasaCero,
}: TransactionListProps) { }: TransactionListProps) {
const [modalOpen, setModalOpen] = useState(false); const [modalOpen, setModalOpen] = useState(false);
const [editing, setEditing] = useState<Transaction | null>(null); const [editing, setEditing] = useState<Transaction | null>(null);
@@ -165,6 +168,7 @@ export default function TransactionList({
onEdit: handleEdit, onEdit: handleEdit,
onDelete: (id) => setDeleteId(id), onDelete: (id) => setDeleteId(id),
onToggleDeferred, onToggleDeferred,
onConvertTasaCero,
selection: { selection: {
selected, selected,
allSelected, allSelected,
@@ -182,7 +186,7 @@ export default function TransactionList({
}, },
}), }),
// eslint-disable-next-line react-hooks/exhaustive-deps // 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 const visibleTransactions = pendingDeletes.size
@@ -331,6 +335,11 @@ export default function TransactionList({
? <ArrowLeftRight className="w-3.5 h-3.5 text-muted-foreground shrink-0" /> ? <ArrowLeftRight className="w-3.5 h-3.5 text-muted-foreground shrink-0" />
: null : null
)} )}
{tx.is_installment_anchor && (
<span className="text-[10px] text-violet-600 border border-violet-300 rounded px-1 shrink-0">
Tasa Cero
</span>
)}
</div> </div>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{new Date(tx.date).toLocaleDateString('es-CR', { month: 'short', day: 'numeric' })} {new Date(tx.date).toLocaleDateString('es-CR', { month: 'short', day: 'numeric' })}
@@ -343,14 +352,30 @@ export default function TransactionList({
data-sensitive data-sensitive
className={cn( className={cn(
'font-mono text-sm font-medium shrink-0', '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' ? '+' : '-'} {tx.transaction_type === 'DEVOLUCION' ? '+' : '-'}
{formatAmount(tx.amount, tx.currency)} {formatAmount(tx.amount, tx.currency)}
</span> </span>
<div className="flex items-center gap-0.5 shrink-0"> <div className="flex items-center gap-0.5 shrink-0">
{onToggleDeferred && ( {onConvertTasaCero &&
tx.transaction_type === 'COMPRA' &&
tx.installment_plan_id == null && (
<Button
variant="ghost"
size="icon"
title={tx.is_installment_anchor ? 'Editar plan Tasa Cero' : 'Convertir a Tasa Cero'}
aria-label={tx.is_installment_anchor ? 'Edit Tasa Cero plan' : 'Convert to Tasa Cero'}
onClick={() => onConvertTasaCero(tx)}
className={cn(tx.is_installment_anchor && 'text-violet-600')}
>
<Layers className="w-4 h-4" />
</Button>
)}
{onToggleDeferred && tx.installment_plan_id == null && (
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"

View File

@@ -0,0 +1,278 @@
import { useEffect, useMemo, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { toast } from 'sonner';
import {
createInstallmentPlan,
deleteInstallmentPlan,
getInstallmentPlans,
updateInstallmentPlan,
type InstallmentPlan,
type Transaction,
} from '@/lib/api';
import { formatAmount } from '@/lib/format';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
/** Mirror of the backend cuota split (services/installments.py): truncate to
* 0.10, last cuota absorbs the remainder. Preview only — the backend is the
* source of truth. */
function computeAmounts(total: number, n: number): number[] {
const per = Math.floor((total / n) * 10) / 10;
const last = Math.round((total - per * (n - 1)) * 100) / 100;
return [...Array<number>(n - 1).fill(per), last];
}
/** Mirror of the backend schedule: cuota 1 on the chosen date, then the 19th
* of consecutive billing-cycle months (cycles cut on the 18th). */
function computeDates(first: Date, n: number): Date[] {
let cy = first.getFullYear();
let cm = first.getMonth();
if (first.getDate() < 18) {
cm -= 1;
if (cm < 0) {
cm = 11;
cy -= 1;
}
}
const dates = [first];
for (let i = 1; i < n; i++) dates.push(new Date(cy, cm + i, 19));
return dates;
}
function toDateInputValue(iso: string): string {
return iso.slice(0, 10);
}
interface ConvertToInstallmentsDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onSaved: () => void;
/** Create mode: the transaction to convert. If it is already an anchor,
* its plan is looked up and the dialog switches to edit mode. */
tx?: Transaction | null;
/** Edit mode: the plan to edit (e.g. from the Financiamientos page). */
plan?: InstallmentPlan | null;
}
export default function ConvertToInstallmentsDialog({
open,
onOpenChange,
onSaved,
tx,
plan,
}: ConvertToInstallmentsDialogProps) {
// An anchor transaction without an explicit plan -> find it in the list.
const needsLookup = open && !plan && !!tx?.is_installment_anchor;
const plansQ = useQuery({
queryKey: ['installment-plans'],
queryFn: ({ signal: _s }) => getInstallmentPlans().then((r) => r.data),
enabled: needsLookup,
});
const effectivePlan =
plan ??
(needsLookup
? plansQ.data?.plans.find((p) => p.anchor_transaction_id === tx?.id) ?? null
: null);
const isEdit = !!effectivePlan;
const total = effectivePlan ? effectivePlan.total_amount : tx?.amount ?? 0;
const currency = effectivePlan ? effectivePlan.currency : tx?.currency ?? 'CRC';
const merchant = effectivePlan ? effectivePlan.merchant : tx?.merchant ?? '';
const purchaseDate = effectivePlan
? effectivePlan.purchase_date
: tx?.date ?? new Date().toISOString();
const [numStr, setNumStr] = useState('3');
const [firstDate, setFirstDate] = useState('');
const [saving, setSaving] = useState(false);
useEffect(() => {
if (!open) return;
setNumStr(String(effectivePlan?.num_installments ?? 3));
setFirstDate(
toDateInputValue(effectivePlan?.first_installment_date ?? purchaseDate),
);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, effectivePlan?.id, tx?.id]);
const n = parseInt(numStr, 10);
const validN = Number.isFinite(n) && n >= 2 && n <= 48;
const preview = useMemo(() => {
if (!validN || !firstDate) return [];
const amounts = computeAmounts(total, n);
const dates = computeDates(new Date(`${firstDate}T00:00:00`), n);
return amounts.map((amount, i) => ({ amount, date: dates[i] }));
}, [validN, n, firstDate, total]);
const handleSave = async () => {
if (!validN) return;
setSaving(true);
try {
if (isEdit && effectivePlan) {
await updateInstallmentPlan(effectivePlan.id, {
num_installments: n,
first_installment_date: firstDate,
});
toast.success('Plan Tasa Cero actualizado');
} else if (tx) {
await createInstallmentPlan({
transaction_id: tx.id,
num_installments: n,
first_installment_date: firstDate,
});
toast.success(`Convertida a Tasa Cero (${n} cuotas)`);
}
onSaved();
onOpenChange(false);
} catch {
toast.error('No se pudo guardar el plan Tasa Cero');
} finally {
setSaving(false);
}
};
const handleUnconvert = async () => {
if (!effectivePlan) return;
const confirmed = window.confirm(
'Se eliminarán las cuotas y la compra volverá a contar como un solo cargo. ¿Continuar?',
);
if (!confirmed) return;
setSaving(true);
try {
await deleteInstallmentPlan(effectivePlan.id);
toast.success('Plan Tasa Cero eliminado');
onSaved();
onOpenChange(false);
} catch {
toast.error('No se pudo eliminar el plan');
} finally {
setSaving(false);
}
};
return (
<Dialog open={open} onOpenChange={(next) => !saving && onOpenChange(next)}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>
{isEdit ? 'Editar plan Tasa Cero' : 'Convertir a Tasa Cero'}
</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="rounded-lg border bg-muted/40 px-3 py-2 text-sm">
<p className="font-medium truncate">{merchant}</p>
<p className="text-muted-foreground">
<span data-sensitive className="font-mono">
{formatAmount(total, currency)}
</span>
{' — '}
{new Date(purchaseDate).toLocaleDateString('es-CR', {
day: 'numeric',
month: 'short',
year: 'numeric',
})}
</p>
</div>
{needsLookup && plansQ.isPending ? (
<p className="text-sm text-muted-foreground">Cargando plan</p>
) : (
<>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label htmlFor="tc-cuotas">Número de cuotas</Label>
<Input
id="tc-cuotas"
type="number"
min={2}
max={48}
value={numStr}
onChange={(e) => setNumStr(e.target.value)}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="tc-first">Primera cuota</Label>
<Input
id="tc-first"
type="date"
value={firstDate}
onChange={(e) => setFirstDate(e.target.value)}
/>
</div>
</div>
{preview.length > 0 && (
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">
Cuotas proyectadas
</Label>
<div className="rounded-lg border divide-y divide-border max-h-44 overflow-y-auto">
{preview.map((c, i) => (
<div
key={i}
className="flex items-center justify-between px-3 py-1.5 text-sm"
>
<span className="text-muted-foreground font-mono text-xs">
{String(i + 1).padStart(2, '0')}/
{String(n).padStart(2, '0')} {' '}
{c.date.toLocaleDateString('es-CR', {
day: 'numeric',
month: 'short',
year: 'numeric',
})}
</span>
<span data-sensitive className="font-mono">
{formatAmount(c.amount, currency)}
</span>
</div>
))}
</div>
</div>
)}
</>
)}
</div>
<DialogFooter className="gap-2 sm:justify-between">
{isEdit ? (
<Button
variant="outline"
className="text-destructive hover:text-destructive"
onClick={handleUnconvert}
disabled={saving}
>
Deshacer Tasa Cero
</Button>
) : (
<span />
)}
<div className="flex gap-2">
<Button
variant="outline"
onClick={() => onOpenChange(false)}
disabled={saving}
>
Cancelar
</Button>
<Button
onClick={handleSave}
disabled={saving || !validN || !firstDate || (needsLookup && !effectivePlan)}
>
{saving ? 'Guardando…' : isEdit ? 'Guardar' : 'Convertir'}
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -1,5 +1,5 @@
import { type ColumnDef } from '@tanstack/react-table'; 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 { type Transaction } from '@/lib/api';
import { formatAmount } from '@/lib/format'; import { formatAmount } from '@/lib/format';
@@ -21,6 +21,7 @@ interface TransactionColumnOptions {
onEdit: (tx: Transaction) => void; onEdit: (tx: Transaction) => void;
onDelete: (txId: number) => void; onDelete: (txId: number) => void;
onToggleDeferred?: (tx: Transaction) => void; onToggleDeferred?: (tx: Transaction) => void;
onConvertTasaCero?: (tx: Transaction) => void;
selection?: RowSelection; selection?: RowSelection;
} }
@@ -30,6 +31,7 @@ export function getTransactionColumns({
onEdit, onEdit,
onDelete, onDelete,
onToggleDeferred, onToggleDeferred,
onConvertTasaCero,
selection, selection,
}: TransactionColumnOptions): ColumnDef<Transaction, unknown>[] { }: TransactionColumnOptions): ColumnDef<Transaction, unknown>[] {
const columns: ColumnDef<Transaction, unknown>[] = []; const columns: ColumnDef<Transaction, unknown>[] = [];
@@ -107,6 +109,16 @@ export function getTransactionColumns({
Diferida Diferida
</Badge> </Badge>
)} )}
{tx.is_installment_anchor && (
<Badge variant="outline" className="ml-1.5 text-[10px] px-1 py-0 shrink-0 text-violet-600 border-violet-300">
Tasa Cero
</Badge>
)}
{tx.installment_plan_id != null && (
<Badge variant="outline" className="ml-1.5 text-[10px] px-1 py-0 shrink-0 text-violet-500/80 border-violet-200">
Cuota
</Badge>
)}
</div> </div>
); );
}, },
@@ -144,7 +156,8 @@ export function getTransactionColumns({
className={cn( className={cn(
'font-mono font-medium', 'font-mono font-medium',
tx.transaction_type !== 'COMPRA' && 'text-primary', 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' ? '-' : '+'} {tx.transaction_type === 'COMPRA' ? '-' : '+'}
@@ -162,7 +175,21 @@ export function getTransactionColumns({
const tx = row.original; const tx = row.original;
return ( return (
<div className="flex items-center justify-end gap-1"> <div className="flex items-center justify-end gap-1">
{onToggleDeferred && ( {onConvertTasaCero &&
tx.transaction_type === 'COMPRA' &&
tx.installment_plan_id == null && (
<Button
variant="ghost"
size="icon"
title={tx.is_installment_anchor ? 'Editar plan Tasa Cero' : 'Convertir a Tasa Cero'}
aria-label={tx.is_installment_anchor ? 'Edit Tasa Cero plan' : 'Convert to Tasa Cero'}
onClick={() => onConvertTasaCero(tx)}
className={cn(tx.is_installment_anchor && 'text-violet-600')}
>
<Layers className="w-4 h-4" />
</Button>
)}
{onToggleDeferred && tx.installment_plan_id == null && (
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"

View File

@@ -14,6 +14,7 @@ import PeriodNavigator from '@/components/PeriodNavigator';
import MonthlyDetail from '@/components/budget/MonthlyDetail'; import MonthlyDetail from '@/components/budget/MonthlyDetail';
import RecurringItemsManager from '@/components/budget/RecurringItemsManager'; import RecurringItemsManager from '@/components/budget/RecurringItemsManager';
import TransactionList from '@/components/TransactionList'; import TransactionList from '@/components/TransactionList';
import ConvertToInstallmentsDialog from '@/components/transactions/ConvertToInstallmentsDialog';
const MIN_YEAR = 2026; const MIN_YEAR = 2026;
@@ -44,6 +45,7 @@ export default function Budget() {
const [subTab, setSubTab] = useState<'detail' | 'transactions'>('detail'); const [subTab, setSubTab] = useState<'detail' | 'transactions'>('detail');
const [txSearch, setTxSearch] = useState(''); const [txSearch, setTxSearch] = useState('');
const [txSource, setTxSource] = useState<'CREDIT_CARD' | 'CASH_AND_TRANSFER'>('CREDIT_CARD'); const [txSource, setTxSource] = useState<'CREDIT_CARD' | 'CASH_AND_TRANSFER'>('CREDIT_CARD');
const [convertTx, setConvertTx] = useState<Transaction | null>(null);
const txQuery = useQuery({ const txQuery = useQuery({
queryKey: ['transactions', 'budget', year, selectedMonth, txSource, txSearch], queryKey: ['transactions', 'budget', year, selectedMonth, txSource, txSearch],
@@ -79,6 +81,7 @@ export default function Budget() {
const invalidateTransactionsAndBudget = useCallback(() => { const invalidateTransactionsAndBudget = useCallback(() => {
queryClient.invalidateQueries({ queryKey: ['transactions'] }); queryClient.invalidateQueries({ queryKey: ['transactions'] });
queryClient.invalidateQueries({ queryKey: ['installment-plans'] });
refresh(); refresh();
}, [queryClient, refresh]); }, [queryClient, refresh]);
@@ -209,6 +212,7 @@ export default function Budget() {
showSourceIcon={txSource === 'CASH_AND_TRANSFER'} showSourceIcon={txSource === 'CASH_AND_TRANSFER'}
emptyMessage={`Sin transacciones de ${txSource === 'CREDIT_CARD' ? 'tarjeta' : 'efectivo o transferencia'} en ${MONTH_NAMES[selectedMonth]}`} emptyMessage={`Sin transacciones de ${txSource === 'CREDIT_CARD' ? 'tarjeta' : 'efectivo o transferencia'} en ${MONTH_NAMES[selectedMonth]}`}
onToggleDeferred={txSource === 'CREDIT_CARD' ? handleToggleDeferred : undefined} onToggleDeferred={txSource === 'CREDIT_CARD' ? handleToggleDeferred : undefined}
onConvertTasaCero={txSource === 'CREDIT_CARD' ? setConvertTx : undefined}
/> />
)} )}
</TabsContent> </TabsContent>
@@ -224,6 +228,13 @@ export default function Budget() {
/> />
</TabsContent> </TabsContent>
</Tabs> </Tabs>
<ConvertToInstallmentsDialog
open={convertTx !== null}
onOpenChange={(open) => !open && setConvertTx(null)}
onSaved={invalidateTransactionsAndBudget}
tx={convertTx}
/>
</div> </div>
); );
} }

View File

@@ -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<InstallmentPlan | null>(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) => (
<TableRow
key={plan.id}
tabIndex={0}
role="button"
aria-label={`Editar plan de ${plan.merchant}`}
className={cn(
'cursor-pointer focus-visible:outline-2 focus-visible:outline-ring',
plan.is_completed && 'opacity-60',
)}
onClick={() => setEditing(plan)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
setEditing(plan);
}
}}
>
<TableCell className="font-medium">
<div className="flex items-center gap-2">
<span className="truncate">{plan.merchant}</span>
{plan.is_completed && (
<Badge variant="outline" className="text-[10px] px-1 py-0 shrink-0">
Completado
</Badge>
)}
</div>
</TableCell>
<TableCell className="text-muted-foreground whitespace-nowrap">
{formatDate(plan.purchase_date)}
</TableCell>
<TableCell className="font-mono text-xs">{progressLabel(plan)}</TableCell>
<TableCell className="text-right font-mono" data-sensitive>
{formatAmount(plan.installment_amount, plan.currency)}
</TableCell>
<TableCell className="text-right font-mono" data-sensitive>
{formatAmount(plan.total_amount, plan.currency)}
</TableCell>
<TableCell className="text-right font-mono font-medium" data-sensitive>
{formatAmount(plan.remaining_amount, plan.currency)}
</TableCell>
<TableCell className="text-muted-foreground whitespace-nowrap">
{plan.next_cuota_date ? formatDate(plan.next_cuota_date) : '—'}
</TableCell>
</TableRow>
));
return (
<div className="space-y-6">
<div className="flex items-center gap-3">
<Layers className="w-6 h-6 text-primary" />
<h1 className="text-2xl font-bold tracking-tight">Financiamientos</h1>
</div>
<p className="text-sm text-muted-foreground -mt-4">
Compras Tasa Cero pagadas en cuotas mensuales sin intereses. Las cuotas
futuras ya cuentan en el presupuesto de sus meses.
</p>
{/* Summary */}
<div className="grid gap-4 sm:grid-cols-3">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
Saldo faltante total
</CardTitle>
</CardHeader>
<CardContent>
<span data-sensitive className="text-2xl font-bold font-mono">
{formatAmount(totalRemaining, 'CRC')}
</span>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
Cuotas por mes (activos)
</CardTitle>
</CardHeader>
<CardContent>
<span data-sensitive className="text-2xl font-bold font-mono">
{formatAmount(totalCuotaMes, 'CRC')}
</span>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
Planes activos
</CardTitle>
</CardHeader>
<CardContent>
<span className="text-2xl font-bold font-mono">{active.length}</span>
{completed.length > 0 && (
<span className="ml-2 text-sm text-muted-foreground">
(+{completed.length} completado{completed.length === 1 ? '' : 's'})
</span>
)}
</CardContent>
</Card>
</div>
{/* Plans table */}
{plansQ.isError ? (
<ErrorState
message="No se pudieron cargar los financiamientos"
onRetry={() => plansQ.refetch()}
/>
) : (
<Card>
<CardContent className="p-0 overflow-x-auto">
{plans.length === 0 && !plansQ.isPending ? (
<div className="px-5 py-16 text-center text-muted-foreground text-sm">
<Layers className="w-8 h-8 mx-auto mb-3 text-muted-foreground/50" />
<p>
Sin financiamientos. Convertí una compra de tarjeta a Tasa
Cero desde la lista de transacciones.
</p>
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Comercio</TableHead>
<TableHead>Fecha compra</TableHead>
<TableHead>Cuotas</TableHead>
<TableHead className="text-right">Monto cuota</TableHead>
<TableHead className="text-right">Saldo inicial</TableHead>
<TableHead className="text-right">Saldo faltante</TableHead>
<TableHead>Próxima cuota</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{renderRows(active)}
{renderRows(completed)}
</TableBody>
</Table>
)}
</CardContent>
</Card>
)}
<ConvertToInstallmentsDialog
open={editing !== null}
onOpenChange={(open) => !open && setEditing(null)}
onSaved={handleSaved}
plan={editing}
/>
</div>
);
}