mirror of
https://github.com/escalante29/WealthySmart.git
synced 2026-07-17 15:28:47 +02:00
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>
218 lines
8.2 KiB
TypeScript
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>
|
|
);
|
|
}
|