Files
WealthySmart/frontend/src/pages/Financiamientos.tsx
Carlos Escalante 3a258537fc
All checks were successful
Deploy to VPS / test (push) Successful in 1m29s
Deploy to VPS / deploy (push) Successful in 2m19s
P2 tail: cuota progress bars, Sync item rows, dialog polish
Financiamientos shows cuota progress as Progress bars; Sincronización
uses a single Card of Item rows; Items Recurrentes table wrapped in a
Card like every other table; both window.confirm sites replaced with
the app's ConfirmDialog.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 13:22:15 -06:00

221 lines
7.5 KiB
TypeScript

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 { Progress } from '@/components/ui/progress';
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 { PageHeader } from '@/components/page-header';
import ConvertToInstallmentsDialog from '@/components/transactions/ConvertToInstallmentsDialog';
function formatDate(iso: string) {
return new Date(iso).toLocaleDateString('es-CR', {
day: 'numeric',
month: 'short',
year: 'numeric',
});
}
function CuotasProgress({ plan }: { plan: InstallmentPlan }) {
const pct = (plan.cuotas_billed / plan.num_installments) * 100;
return (
<div className="flex items-center gap-2">
<Progress
value={pct}
className="w-16"
aria-label={`${plan.cuotas_billed} de ${plan.num_installments} cuotas pagadas`}
/>
<span className="font-mono text-xs text-muted-foreground whitespace-nowrap">
{plan.cuotas_billed}/{plan.num_installments}
</span>
</div>
);
}
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>
<CuotasProgress plan={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">
<PageHeader
icon={Layers}
title="Financiamientos"
subtitle="Compras Tasa Cero en cuotas sin intereses — las cuotas futuras ya cuentan en el presupuesto de sus meses."
/>
{/* 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>
);
}