mirror of
https://github.com/escalante29/WealthySmart.git
synced 2026-07-17 10: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:
@@ -20,6 +20,7 @@ import Asistente from "./pages/Asistente";
|
|||||||
import Analytics from "./pages/Analytics";
|
import Analytics from "./pages/Analytics";
|
||||||
import Budget from "./pages/Budget";
|
import Budget from "./pages/Budget";
|
||||||
import Salarios from "./pages/Salarios";
|
import Salarios from "./pages/Salarios";
|
||||||
|
import Financiamientos from "./pages/Financiamientos";
|
||||||
import Pensions from "./pages/Pensions";
|
import Pensions from "./pages/Pensions";
|
||||||
import Proyecciones from "./pages/Proyecciones";
|
import Proyecciones from "./pages/Proyecciones";
|
||||||
import ServiciosMunicipales from "./pages/ServiciosMunicipales";
|
import ServiciosMunicipales from "./pages/ServiciosMunicipales";
|
||||||
@@ -56,6 +57,7 @@ function AppRoutes() {
|
|||||||
<Route path="/analytics" element={<Analytics />} />
|
<Route path="/analytics" element={<Analytics />} />
|
||||||
<Route path="/proyecciones" element={<Proyecciones />} />
|
<Route path="/proyecciones" element={<Proyecciones />} />
|
||||||
<Route path="/salarios" element={<Salarios />} />
|
<Route path="/salarios" element={<Salarios />} />
|
||||||
|
<Route path="/financiamientos" element={<Financiamientos />} />
|
||||||
<Route path="/pensions" element={<Pensions />} />
|
<Route path="/pensions" element={<Pensions />} />
|
||||||
<Route path="/servicios-municipales" element={<ServiciosMunicipales />} />
|
<Route path="/servicios-municipales" element={<ServiciosMunicipales />} />
|
||||||
<Route path="/sync" element={<SyncStatus />} />
|
<Route path="/sync" element={<SyncStatus />} />
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
Landmark,
|
Landmark,
|
||||||
PiggyBank,
|
PiggyBank,
|
||||||
Droplets,
|
Droplets,
|
||||||
|
Layers,
|
||||||
LogOut,
|
LogOut,
|
||||||
TrendingUp,
|
TrendingUp,
|
||||||
Wallet,
|
Wallet,
|
||||||
@@ -54,6 +55,7 @@ const navSections: NavSection[] = [
|
|||||||
items: [
|
items: [
|
||||||
{ to: "/budget", icon: Calculator, label: "Presupuesto" },
|
{ to: "/budget", icon: Calculator, label: "Presupuesto" },
|
||||||
{ to: "/salarios", icon: Landmark, label: "Salarios" },
|
{ to: "/salarios", icon: Landmark, label: "Salarios" },
|
||||||
|
{ to: "/financiamientos", icon: Layers, label: "Financiamientos" },
|
||||||
{ to: "/pensions", icon: PiggyBank, label: "Pensiones" },
|
{ to: "/pensions", icon: PiggyBank, label: "Pensiones" },
|
||||||
{ to: "/proyecciones", icon: TrendingUp, label: "Proyecciones" },
|
{ to: "/proyecciones", icon: TrendingUp, label: "Proyecciones" },
|
||||||
{ to: "/planificador", icon: Telescope, label: "Planificador" },
|
{ to: "/planificador", icon: Telescope, label: "Planificador" },
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
ArrowLeftRight,
|
ArrowLeftRight,
|
||||||
ArrowRightFromLine,
|
ArrowRightFromLine,
|
||||||
Banknote,
|
Banknote,
|
||||||
|
Layers,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
@@ -46,6 +47,7 @@ export interface TransactionListProps {
|
|||||||
showSourceIcon?: boolean;
|
showSourceIcon?: boolean;
|
||||||
addLabel?: string;
|
addLabel?: string;
|
||||||
onToggleDeferred?: (tx: Transaction) => void;
|
onToggleDeferred?: (tx: Transaction) => void;
|
||||||
|
onConvertTasaCero?: (tx: Transaction) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function TransactionList({
|
export default function TransactionList({
|
||||||
@@ -61,6 +63,7 @@ export default function TransactionList({
|
|||||||
showSourceIcon = false,
|
showSourceIcon = false,
|
||||||
addLabel = 'Add Transaction',
|
addLabel = 'Add Transaction',
|
||||||
onToggleDeferred,
|
onToggleDeferred,
|
||||||
|
onConvertTasaCero,
|
||||||
}: TransactionListProps) {
|
}: TransactionListProps) {
|
||||||
const [modalOpen, setModalOpen] = useState(false);
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
const [editing, setEditing] = useState<Transaction | null>(null);
|
const [editing, setEditing] = useState<Transaction | null>(null);
|
||||||
@@ -165,6 +168,7 @@ export default function TransactionList({
|
|||||||
onEdit: handleEdit,
|
onEdit: handleEdit,
|
||||||
onDelete: (id) => setDeleteId(id),
|
onDelete: (id) => setDeleteId(id),
|
||||||
onToggleDeferred,
|
onToggleDeferred,
|
||||||
|
onConvertTasaCero,
|
||||||
selection: {
|
selection: {
|
||||||
selected,
|
selected,
|
||||||
allSelected,
|
allSelected,
|
||||||
@@ -182,7 +186,7 @@ export default function TransactionList({
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// 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
|
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" />
|
? <ArrowLeftRight className="w-3.5 h-3.5 text-muted-foreground shrink-0" />
|
||||||
: null
|
: 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>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{new Date(tx.date).toLocaleDateString('es-CR', { month: 'short', day: 'numeric' })}
|
{new Date(tx.date).toLocaleDateString('es-CR', { month: 'short', day: 'numeric' })}
|
||||||
@@ -343,14 +352,30 @@ export default function TransactionList({
|
|||||||
data-sensitive
|
data-sensitive
|
||||||
className={cn(
|
className={cn(
|
||||||
'font-mono text-sm font-medium shrink-0',
|
'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' ? '+' : '-'}
|
{tx.transaction_type === 'DEVOLUCION' ? '+' : '-'}
|
||||||
{formatAmount(tx.amount, tx.currency)}
|
{formatAmount(tx.amount, tx.currency)}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex items-center gap-0.5 shrink-0">
|
<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
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
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 { 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 { type Transaction } from '@/lib/api';
|
||||||
import { formatAmount } from '@/lib/format';
|
import { formatAmount } from '@/lib/format';
|
||||||
@@ -21,6 +21,7 @@ interface TransactionColumnOptions {
|
|||||||
onEdit: (tx: Transaction) => void;
|
onEdit: (tx: Transaction) => void;
|
||||||
onDelete: (txId: number) => void;
|
onDelete: (txId: number) => void;
|
||||||
onToggleDeferred?: (tx: Transaction) => void;
|
onToggleDeferred?: (tx: Transaction) => void;
|
||||||
|
onConvertTasaCero?: (tx: Transaction) => void;
|
||||||
selection?: RowSelection;
|
selection?: RowSelection;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -30,6 +31,7 @@ export function getTransactionColumns({
|
|||||||
onEdit,
|
onEdit,
|
||||||
onDelete,
|
onDelete,
|
||||||
onToggleDeferred,
|
onToggleDeferred,
|
||||||
|
onConvertTasaCero,
|
||||||
selection,
|
selection,
|
||||||
}: TransactionColumnOptions): ColumnDef<Transaction, unknown>[] {
|
}: TransactionColumnOptions): ColumnDef<Transaction, unknown>[] {
|
||||||
const columns: ColumnDef<Transaction, unknown>[] = [];
|
const columns: ColumnDef<Transaction, unknown>[] = [];
|
||||||
@@ -107,6 +109,16 @@ export function getTransactionColumns({
|
|||||||
Diferida
|
Diferida
|
||||||
</Badge>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -144,7 +156,8 @@ export function getTransactionColumns({
|
|||||||
className={cn(
|
className={cn(
|
||||||
'font-mono font-medium',
|
'font-mono font-medium',
|
||||||
tx.transaction_type !== 'COMPRA' && 'text-primary',
|
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' ? '-' : '+'}
|
{tx.transaction_type === 'COMPRA' ? '-' : '+'}
|
||||||
@@ -162,7 +175,21 @@ export function getTransactionColumns({
|
|||||||
const tx = row.original;
|
const tx = row.original;
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-end gap-1">
|
<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
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import PeriodNavigator from '@/components/PeriodNavigator';
|
|||||||
import MonthlyDetail from '@/components/budget/MonthlyDetail';
|
import MonthlyDetail from '@/components/budget/MonthlyDetail';
|
||||||
import RecurringItemsManager from '@/components/budget/RecurringItemsManager';
|
import RecurringItemsManager from '@/components/budget/RecurringItemsManager';
|
||||||
import TransactionList from '@/components/TransactionList';
|
import TransactionList from '@/components/TransactionList';
|
||||||
|
import ConvertToInstallmentsDialog from '@/components/transactions/ConvertToInstallmentsDialog';
|
||||||
|
|
||||||
|
|
||||||
const MIN_YEAR = 2026;
|
const MIN_YEAR = 2026;
|
||||||
@@ -44,6 +45,7 @@ export default function Budget() {
|
|||||||
const [subTab, setSubTab] = useState<'detail' | 'transactions'>('detail');
|
const [subTab, setSubTab] = useState<'detail' | 'transactions'>('detail');
|
||||||
const [txSearch, setTxSearch] = useState('');
|
const [txSearch, setTxSearch] = useState('');
|
||||||
const [txSource, setTxSource] = useState<'CREDIT_CARD' | 'CASH_AND_TRANSFER'>('CREDIT_CARD');
|
const [txSource, setTxSource] = useState<'CREDIT_CARD' | 'CASH_AND_TRANSFER'>('CREDIT_CARD');
|
||||||
|
const [convertTx, setConvertTx] = useState<Transaction | null>(null);
|
||||||
|
|
||||||
const txQuery = useQuery({
|
const txQuery = useQuery({
|
||||||
queryKey: ['transactions', 'budget', year, selectedMonth, txSource, txSearch],
|
queryKey: ['transactions', 'budget', year, selectedMonth, txSource, txSearch],
|
||||||
@@ -79,6 +81,7 @@ export default function Budget() {
|
|||||||
|
|
||||||
const invalidateTransactionsAndBudget = useCallback(() => {
|
const invalidateTransactionsAndBudget = useCallback(() => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['transactions'] });
|
queryClient.invalidateQueries({ queryKey: ['transactions'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['installment-plans'] });
|
||||||
refresh();
|
refresh();
|
||||||
}, [queryClient, refresh]);
|
}, [queryClient, refresh]);
|
||||||
|
|
||||||
@@ -209,6 +212,7 @@ export default function Budget() {
|
|||||||
showSourceIcon={txSource === 'CASH_AND_TRANSFER'}
|
showSourceIcon={txSource === 'CASH_AND_TRANSFER'}
|
||||||
emptyMessage={`Sin transacciones de ${txSource === 'CREDIT_CARD' ? 'tarjeta' : 'efectivo o transferencia'} en ${MONTH_NAMES[selectedMonth]}`}
|
emptyMessage={`Sin transacciones de ${txSource === 'CREDIT_CARD' ? 'tarjeta' : 'efectivo o transferencia'} en ${MONTH_NAMES[selectedMonth]}`}
|
||||||
onToggleDeferred={txSource === 'CREDIT_CARD' ? handleToggleDeferred : undefined}
|
onToggleDeferred={txSource === 'CREDIT_CARD' ? handleToggleDeferred : undefined}
|
||||||
|
onConvertTasaCero={txSource === 'CREDIT_CARD' ? setConvertTx : undefined}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
@@ -224,6 +228,13 @@ export default function Budget() {
|
|||||||
/>
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
|
<ConvertToInstallmentsDialog
|
||||||
|
open={convertTx !== null}
|
||||||
|
onOpenChange={(open) => !open && setConvertTx(null)}
|
||||||
|
onSaved={invalidateTransactionsAndBudget}
|
||||||
|
tx={convertTx}
|
||||||
|
/>
|
||||||
</div>
|
</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