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

@@ -14,6 +14,7 @@ import PeriodNavigator from '@/components/PeriodNavigator';
import MonthlyDetail from '@/components/budget/MonthlyDetail';
import RecurringItemsManager from '@/components/budget/RecurringItemsManager';
import TransactionList from '@/components/TransactionList';
import ConvertToInstallmentsDialog from '@/components/transactions/ConvertToInstallmentsDialog';
const MIN_YEAR = 2026;
@@ -44,6 +45,7 @@ export default function Budget() {
const [subTab, setSubTab] = useState<'detail' | 'transactions'>('detail');
const [txSearch, setTxSearch] = useState('');
const [txSource, setTxSource] = useState<'CREDIT_CARD' | 'CASH_AND_TRANSFER'>('CREDIT_CARD');
const [convertTx, setConvertTx] = useState<Transaction | null>(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}
/>
)}
</TabsContent>
@@ -224,6 +228,13 @@ export default function Budget() {
/>
</TabsContent>
</Tabs>
<ConvertToInstallmentsDialog
open={convertTx !== null}
onOpenChange={(open) => !open && setConvertTx(null)}
onSaved={invalidateTransactionsAndBudget}
tx={convertTx}
/>
</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>
);
}