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