mirror of
https://github.com/escalante29/WealthySmart.git
synced 2026-07-17 09:08:46 +02:00
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>
221 lines
7.5 KiB
TypeScript
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>
|
|
);
|
|
}
|