Files
WealthySmart/frontend/src/pages/Budget.tsx
Carlos Escalante 3d71234a61 Budget: unify transactions section into one card
Toolbar (source tabs, search, CSV, add) and a summary strip (billing
cycle range, count, per-currency total) now live inside the table card
instead of five loose control rows. Cycle range 18-to-18 is finally
visible. Stray English strings translated; back-button spacing fixed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 09:38:36 -06:00

263 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useCallback } from 'react';
import { ArrowLeft, Calculator, Download } from 'lucide-react';
import { useLocation, useNavigate } from 'react-router-dom';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import api, { type Transaction } from '@/lib/api';
import {
MONTH_NAMES_ES as MONTH_NAMES,
MONTH_NAMES_ES_SHORT as MONTH_NAMES_SHORT,
} from '@/lib/dates';
import { useBudget } from '@/hooks/useBudget';
import { Button } from '@/components/ui/button';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import ErrorState from '@/components/ErrorState';
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;
const MAX_YEAR = 2030;
export default function Budget() {
const currentYear = Math.max(MIN_YEAR, new Date().getFullYear());
const location = useLocation();
const navigate = useNavigate();
// Proyecciones row clicks land here with the clicked period in the state.
const navState = location.state as
| { from?: string; year?: number; month?: number }
| null;
const cameFromProyecciones = navState?.from === 'proyecciones';
const initialYear =
navState?.year && navState.year >= MIN_YEAR && navState.year <= MAX_YEAR
? navState.year
: currentYear;
const {
year,
setYear,
selectedMonth,
setSelectedMonth,
monthDetail,
recurringItems,
monthLoading,
error: budgetError,
addItem,
updateItem,
deleteItem,
refresh,
} = useBudget(initialYear, navState?.month);
const queryClient = useQueryClient();
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],
queryFn: async ({ signal }) => {
const params: Record<string, string | number | boolean | undefined> = {
search: txSearch || undefined,
limit: 200,
};
if (txSource === 'CREDIT_CARD') {
params.source = 'CREDIT_CARD';
// Credit card: billing cycle that ends around the 18th of selectedMonth
const prevMonth = selectedMonth === 1 ? 12 : selectedMonth - 1;
const prevYear = selectedMonth === 1 ? year - 1 : year;
params.cycle_year = prevYear;
params.cycle_month = prevMonth;
} else {
// Cash + Transfer merged: calendar month, exclude credit card
params.exclude_source = 'CREDIT_CARD';
const startDate = `${year}-${String(selectedMonth).padStart(2, '0')}-01`;
const endMonth = selectedMonth === 12 ? 1 : selectedMonth + 1;
const endYear = selectedMonth === 12 ? year + 1 : year;
const endDate = `${endYear}-${String(endMonth).padStart(2, '0')}-01`;
params.start_date = startDate;
params.end_date = endDate;
}
const { data } = await api.get<Transaction[]>('/transactions/', { params, signal });
const INCOME_TYPES = ['DEPOSITO', 'SALARY'];
return data.filter((tx) => !INCOME_TYPES.includes(tx.transaction_type));
},
placeholderData: (prev) => prev,
});
const transactions = txQuery.data ?? [];
// Make the period explicit: budget month M on the card tab = the billing
// cycle 18/(M-1) → 18/M; cash/transfers use the calendar month.
const cyclePrevMonth = selectedMonth === 1 ? 12 : selectedMonth - 1;
const cycleLabel =
txSource === 'CREDIT_CARD'
? `Ciclo 18 ${MONTH_NAMES_SHORT[cyclePrevMonth].toLowerCase()} 18 ${MONTH_NAMES_SHORT[selectedMonth].toLowerCase()}`
: `Mes de ${MONTH_NAMES[selectedMonth].toLowerCase()}`;
const invalidateTransactionsAndBudget = useCallback(() => {
queryClient.invalidateQueries({ queryKey: ['transactions'] });
queryClient.invalidateQueries({ queryKey: ['installment-plans'] });
refresh();
}, [queryClient, refresh]);
const handleToggleDeferred = useCallback(async (tx: Transaction) => {
try {
await api.patch(`/transactions/${tx.id}`, {
deferred_to_next_cycle: !tx.deferred_to_next_cycle,
});
toast.success(
tx.deferred_to_next_cycle
? 'Transacción restaurada a su ciclo original'
: 'Transacción diferida al siguiente ciclo',
);
} catch {
toast.error('No se pudo actualizar la transacción');
return;
}
invalidateTransactionsAndBudget();
}, [invalidateTransactionsAndBudget]);
const handleNavigateToTransactions = useCallback(() => {
setTxSource('CASH_AND_TRANSFER');
setSubTab('transactions');
}, []);
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
{cameFromProyecciones && (
<Button
variant="ghost"
size="sm"
className="mr-4 text-muted-foreground"
onClick={() => navigate('/proyecciones')}
aria-label="Volver a Proyecciones"
>
<ArrowLeft className="w-4 h-4 mr-1" aria-hidden="true" />
Proyecciones
</Button>
)}
<Calculator className="w-6 h-6 text-primary" />
<h1 className="text-2xl font-bold tracking-tight">Presupuesto</h1>
</div>
<PeriodNavigator
label={String(year)}
onPrev={() => setYear(year - 1)}
onNext={() => setYear(year + 1)}
prevDisabled={year <= MIN_YEAR}
nextDisabled={year >= MAX_YEAR}
/>
</div>
<Tabs defaultValue="overview">
<TabsList>
<TabsTrigger value="overview">Resumen</TabsTrigger>
<TabsTrigger value="items">Items Recurrentes</TabsTrigger>
</TabsList>
<TabsContent value="overview" className="mt-4">
<Tabs
value={subTab}
onValueChange={(v) => setSubTab(v as typeof subTab)}
>
<div className="flex items-center justify-between">
<TabsList variant="line">
<TabsTrigger value="detail">Detalle</TabsTrigger>
<TabsTrigger value="transactions">Transacciones</TabsTrigger>
</TabsList>
<PeriodNavigator
variant="ghost"
label={`${MONTH_NAMES[selectedMonth]} ${year}`}
labelClassName="text-sm font-medium w-28 text-center"
onPrev={() => setSelectedMonth(selectedMonth - 1)}
onNext={() => setSelectedMonth(selectedMonth + 1)}
prevDisabled={selectedMonth <= 1}
nextDisabled={selectedMonth >= 12}
/>
</div>
<TabsContent value="detail" className="space-y-6 mt-4">
{budgetError ? (
<ErrorState onRetry={refresh} />
) : (
<MonthlyDetail
detail={monthDetail}
loading={monthLoading || !monthDetail}
onNavigateToTransactions={handleNavigateToTransactions}
/>
)}
</TabsContent>
<TabsContent value="transactions" className="mt-4">
{txQuery.isError ? (
<ErrorState
message="No se pudieron cargar las transacciones"
onRetry={() => txQuery.refetch()}
/>
) : (
<TransactionList
transactions={transactions}
loading={txQuery.isPending}
source={txSource === 'CREDIT_CARD' ? 'CREDIT_CARD' : 'CASH'}
search={txSearch}
onSearchChange={setTxSearch}
onRefresh={invalidateTransactionsAndBudget}
showCategory={txSource === 'CREDIT_CARD'}
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}
summaryLabel={cycleLabel}
toolbarLeft={
<Tabs
value={txSource}
onValueChange={(v) => setTxSource(v as typeof txSource)}
>
<TabsList>
<TabsTrigger value="CREDIT_CARD">Tarjeta</TabsTrigger>
<TabsTrigger value="CASH_AND_TRANSFER">Efectivo y Transferencias</TabsTrigger>
</TabsList>
</Tabs>
}
toolbarRight={
<Button
variant="outline"
size="sm"
onClick={() => window.open('/api/v1/transactions/export', '_blank')}
title="Descargar todas las transacciones como CSV"
>
<Download className="w-4 h-4 mr-2" aria-hidden="true" />
CSV
</Button>
}
/>
)}
</TabsContent>
</Tabs>
</TabsContent>
<TabsContent value="items" className="mt-4">
<RecurringItemsManager
items={recurringItems}
onAdd={addItem}
onUpdate={updateItem}
onDelete={deleteItem}
/>
</TabsContent>
</Tabs>
<ConvertToInstallmentsDialog
open={convertTx !== null}
onOpenChange={(open) => !open && setConvertTx(null)}
onSaved={invalidateTransactionsAndBudget}
tx={convertTx}
/>
</div>
);
}