mirror of
https://github.com/escalante29/WealthySmart.git
synced 2026-07-17 14:28:47 +02:00
Tasa Cero: badges, convert dialog, Financiamientos page
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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
207
frontend/src/pages/Financiamientos.tsx
Normal file
207
frontend/src/pages/Financiamientos.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user