Files
WealthySmart/frontend/src/pages/Budget.tsx
Carlos Escalante 8b7f3dff40 Localization sweep: shared es-CR dates module, no more en-US strays
src/lib/dates.ts is the single source for month names and date
formatting; the three per-page month arrays are gone (with an
off-by-one guard — the shared array is 1-indexed). formatDate and the
Analytics chart tooltips now speak es-CR, Analytics headings are
Spanish, and the cycle selector says 'Todo el período' (UX-19, FE-17,
UX-07).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 17:59:16 -06:00

218 lines
8.2 KiB
TypeScript

import { useState, useCallback } from 'react';
import { ChevronLeft, ChevronRight, Calculator } from 'lucide-react';
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 } 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 MonthlyDetail from '@/components/budget/MonthlyDetail';
import RecurringItemsManager from '@/components/budget/RecurringItemsManager';
import TransactionList from '@/components/TransactionList';
const MIN_YEAR = 2026;
const MAX_YEAR = 2030;
export default function Budget() {
const currentYear = Math.max(MIN_YEAR, new Date().getFullYear());
const {
year,
setYear,
selectedMonth,
setSelectedMonth,
monthDetail,
recurringItems,
monthLoading,
error: budgetError,
addItem,
updateItem,
deleteItem,
refresh,
} = useBudget(currentYear);
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 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 ?? [];
const invalidateTransactionsAndBudget = useCallback(() => {
queryClient.invalidateQueries({ queryKey: ['transactions'] });
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">
<Calculator className="w-6 h-6 text-primary" />
<h1 className="text-2xl font-bold tracking-tight">Presupuesto</h1>
</div>
<div className="flex items-center gap-1">
<Button variant="outline" size="icon" disabled={year <= MIN_YEAR} onClick={() => setYear(year - 1)}>
<ChevronLeft className="w-4 h-4" />
</Button>
<span className="w-16 text-center font-semibold tabular-nums">{year}</span>
<Button variant="outline" size="icon" disabled={year >= MAX_YEAR} onClick={() => setYear(year + 1)}>
<ChevronRight className="w-4 h-4" />
</Button>
</div>
</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>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
disabled={selectedMonth <= 1}
onClick={() => setSelectedMonth(selectedMonth - 1)}
>
<ChevronLeft className="w-4 h-4" />
</Button>
<span className="text-sm font-medium w-28 text-center">
{MONTH_NAMES[selectedMonth]} {year}
</span>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
disabled={selectedMonth >= 12}
onClick={() => setSelectedMonth(selectedMonth + 1)}
>
<ChevronRight className="w-4 h-4" />
</Button>
</div>
</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="space-y-3 mt-4">
<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>
{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}
/>
)}
</TabsContent>
</Tabs>
</TabsContent>
<TabsContent value="items" className="mt-4">
<RecurringItemsManager
items={recurringItems}
onAdd={addItem}
onUpdate={updateItem}
onDelete={deleteItem}
/>
</TabsContent>
</Tabs>
</div>
);
}