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

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

View File

@@ -9,6 +9,7 @@ import {
ArrowLeftRight,
ArrowRightFromLine,
Banknote,
Layers,
} from 'lucide-react';
import { toast } from 'sonner';
@@ -46,6 +47,7 @@ export interface TransactionListProps {
showSourceIcon?: boolean;
addLabel?: string;
onToggleDeferred?: (tx: Transaction) => void;
onConvertTasaCero?: (tx: Transaction) => void;
}
export default function TransactionList({
@@ -61,6 +63,7 @@ export default function TransactionList({
showSourceIcon = false,
addLabel = 'Add Transaction',
onToggleDeferred,
onConvertTasaCero,
}: TransactionListProps) {
const [modalOpen, setModalOpen] = useState(false);
const [editing, setEditing] = useState<Transaction | null>(null);
@@ -165,6 +168,7 @@ export default function TransactionList({
onEdit: handleEdit,
onDelete: (id) => setDeleteId(id),
onToggleDeferred,
onConvertTasaCero,
selection: {
selected,
allSelected,
@@ -182,7 +186,7 @@ export default function TransactionList({
},
}),
// 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
@@ -331,6 +335,11 @@ export default function TransactionList({
? <ArrowLeftRight className="w-3.5 h-3.5 text-muted-foreground shrink-0" />
: 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>
<p className="text-xs text-muted-foreground">
{new Date(tx.date).toLocaleDateString('es-CR', { month: 'short', day: 'numeric' })}
@@ -343,14 +352,30 @@ export default function TransactionList({
data-sensitive
className={cn(
'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' ? '+' : '-'}
{formatAmount(tx.amount, tx.currency)}
</span>
<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
variant="ghost"
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 { 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 { formatAmount } from '@/lib/format';
@@ -21,6 +21,7 @@ interface TransactionColumnOptions {
onEdit: (tx: Transaction) => void;
onDelete: (txId: number) => void;
onToggleDeferred?: (tx: Transaction) => void;
onConvertTasaCero?: (tx: Transaction) => void;
selection?: RowSelection;
}
@@ -30,6 +31,7 @@ export function getTransactionColumns({
onEdit,
onDelete,
onToggleDeferred,
onConvertTasaCero,
selection,
}: TransactionColumnOptions): ColumnDef<Transaction, unknown>[] {
const columns: ColumnDef<Transaction, unknown>[] = [];
@@ -107,6 +109,16 @@ export function getTransactionColumns({
Diferida
</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>
);
},
@@ -144,7 +156,8 @@ export function getTransactionColumns({
className={cn(
'font-mono font-medium',
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' ? '-' : '+'}
@@ -162,7 +175,21 @@ export function getTransactionColumns({
const tx = row.original;
return (
<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
variant="ghost"
size="icon"