mirror of
https://github.com/escalante29/WealthySmart.git
synced 2026-07-17 15: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:
@@ -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" },
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user