fix: Budget Manager shows stale data when switching years
The budgetData was stored in a separate useState and updated inside queryFn. When switching years, React Query served cached data with isLoading=false but the local state still held the previous year's data, causing the "no budget" empty state to flash intermittently. Fix: Use query data directly as source of truth. Local state (editData) is only used when actively editing. Added a small spinner indicator when refetching in the background. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from 'react';
|
import { useState, useMemo } from 'react';
|
||||||
import {
|
import {
|
||||||
Title, Table, Group, Button, Stack, Text, NumberInput,
|
Title, Table, Group, Button, Stack, Text, NumberInput,
|
||||||
Select, Loader, Center, Badge, Card, Alert,
|
Select, Loader, Center, Badge, Card, Alert,
|
||||||
@@ -26,10 +26,6 @@ interface BudgetLine {
|
|||||||
const months = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec_amt'];
|
const months = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec_amt'];
|
||||||
const monthLabels = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
const monthLabels = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
||||||
|
|
||||||
/**
|
|
||||||
* Ensure all monthly values are numbers (PostgreSQL can return strings for NUMERIC columns)
|
|
||||||
* and compute annual_total as the sum of all monthly values.
|
|
||||||
*/
|
|
||||||
function hydrateBudgetLine(row: any): BudgetLine {
|
function hydrateBudgetLine(row: any): BudgetLine {
|
||||||
const line: any = { ...row };
|
const line: any = { ...row };
|
||||||
for (const m of months) {
|
for (const m of months) {
|
||||||
@@ -41,8 +37,7 @@ function hydrateBudgetLine(row: any): BudgetLine {
|
|||||||
|
|
||||||
export function BudgetsPage() {
|
export function BudgetsPage() {
|
||||||
const [year, setYear] = useState(new Date().getFullYear().toString());
|
const [year, setYear] = useState(new Date().getFullYear().toString());
|
||||||
const [budgetData, setBudgetData] = useState<BudgetLine[]>([]);
|
const [editData, setEditData] = useState<BudgetLine[] | null>(null); // null = not editing
|
||||||
const [isEditing, setIsEditing] = useState(false);
|
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const isReadOnly = useIsReadOnly();
|
const isReadOnly = useIsReadOnly();
|
||||||
@@ -52,20 +47,36 @@ export function BudgetsPage() {
|
|||||||
const incomeSectionBg = isDark ? 'var(--mantine-color-green-9)' : '#e6f9e6';
|
const incomeSectionBg = isDark ? 'var(--mantine-color-green-9)' : '#e6f9e6';
|
||||||
const expenseSectionBg = isDark ? 'var(--mantine-color-red-9)' : '#fde8e8';
|
const expenseSectionBg = isDark ? 'var(--mantine-color-red-9)' : '#fde8e8';
|
||||||
|
|
||||||
const hasBudget = budgetData.length > 0;
|
// Query is the single source of truth for budget data
|
||||||
const cellsEditable = !isReadOnly && isEditing;
|
const { data: queryData, isLoading, isFetching } = useQuery<BudgetLine[]>({
|
||||||
|
|
||||||
const { isLoading } = useQuery<BudgetLine[]>({
|
|
||||||
queryKey: ['budgets', year],
|
queryKey: ['budgets', year],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const { data } = await api.get(`/budgets/${year}`);
|
const { data } = await api.get(`/budgets/${year}`);
|
||||||
const hydrated = (data as any[]).map(hydrateBudgetLine);
|
return (data as any[]).map(hydrateBudgetLine);
|
||||||
setBudgetData(hydrated);
|
|
||||||
setIsEditing(false);
|
|
||||||
return hydrated;
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Use edit data when editing, otherwise use query data
|
||||||
|
const isEditing = editData !== null;
|
||||||
|
const budgetData = isEditing ? editData : (queryData || []);
|
||||||
|
const hasBudget = budgetData.length > 0;
|
||||||
|
const cellsEditable = !isReadOnly && isEditing;
|
||||||
|
|
||||||
|
const handleStartEdit = () => {
|
||||||
|
setEditData(queryData ? [...queryData] : []);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancelEdit = () => {
|
||||||
|
setEditData(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleYearChange = (v: string | null) => {
|
||||||
|
if (v) {
|
||||||
|
setYear(v);
|
||||||
|
setEditData(null); // Cancel any in-progress edit when switching years
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const saveMutation = useMutation({
|
const saveMutation = useMutation({
|
||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
const payload = budgetData
|
const payload = budgetData
|
||||||
@@ -81,7 +92,7 @@ export function BudgetsPage() {
|
|||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['budgets', year] });
|
queryClient.invalidateQueries({ queryKey: ['budgets', year] });
|
||||||
setIsEditing(false);
|
setEditData(null);
|
||||||
notifications.show({ message: 'Budget saved', color: 'green' });
|
notifications.show({ message: 'Budget saved', color: 'green' });
|
||||||
},
|
},
|
||||||
onError: (err: any) => {
|
onError: (err: any) => {
|
||||||
@@ -89,25 +100,22 @@ export function BudgetsPage() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleCancelEdit = () => {
|
|
||||||
setIsEditing(false);
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['budgets', year] });
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateCell = (idx: number, month: string, value: number) => {
|
const updateCell = (idx: number, month: string, value: number) => {
|
||||||
const updated = [...budgetData];
|
if (!editData) return;
|
||||||
|
const updated = [...editData];
|
||||||
(updated[idx] as any)[month] = value || 0;
|
(updated[idx] as any)[month] = value || 0;
|
||||||
updated[idx].annual_total = months.reduce((s, m) => s + ((updated[idx] as any)[m] || 0), 0);
|
updated[idx].annual_total = months.reduce((s, m) => s + ((updated[idx] as any)[m] || 0), 0);
|
||||||
setBudgetData(updated);
|
setEditData(updated);
|
||||||
};
|
};
|
||||||
|
|
||||||
const yearOptions = Array.from({ length: 5 }, (_, i) => {
|
const yearOptions = useMemo(() => Array.from({ length: 5 }, (_, i) => {
|
||||||
const y = new Date().getFullYear() - 1 + i;
|
const y = new Date().getFullYear() - 1 + i;
|
||||||
return { value: String(y), label: String(y) };
|
return { value: String(y), label: String(y) };
|
||||||
});
|
}), []);
|
||||||
|
|
||||||
const fmt = (v: number) => v.toLocaleString('en-US', { style: 'currency', currency: 'USD', minimumFractionDigits: 0 });
|
const fmt = (v: number) => v.toLocaleString('en-US', { style: 'currency', currency: 'USD', minimumFractionDigits: 0 });
|
||||||
|
|
||||||
|
// Show loader on initial load or when switching years with no cached data
|
||||||
if (isLoading) return <Center h={300}><Loader /></Center>;
|
if (isLoading) return <Center h={300}><Loader /></Center>;
|
||||||
|
|
||||||
const incomeLines = budgetData.filter((b) => b.account_type === 'income');
|
const incomeLines = budgetData.filter((b) => b.account_type === 'income');
|
||||||
@@ -123,14 +131,15 @@ export function BudgetsPage() {
|
|||||||
<Group justify="space-between">
|
<Group justify="space-between">
|
||||||
<Title order={2}>Budget Manager</Title>
|
<Title order={2}>Budget Manager</Title>
|
||||||
<Group>
|
<Group>
|
||||||
<Select data={yearOptions} value={year} onChange={(v) => v && setYear(v)} w={120} />
|
<Select data={yearOptions} value={year} onChange={handleYearChange} w={120} />
|
||||||
|
{isFetching && !isLoading && <Loader size="xs" />}
|
||||||
{!isReadOnly && hasBudget && (
|
{!isReadOnly && hasBudget && (
|
||||||
<>
|
<>
|
||||||
{!isEditing ? (
|
{!isEditing ? (
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
leftSection={<IconPencil size={16} />}
|
leftSection={<IconPencil size={16} />}
|
||||||
onClick={() => setIsEditing(true)}
|
onClick={handleStartEdit}
|
||||||
>
|
>
|
||||||
Edit Budget
|
Edit Budget
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
Reference in New Issue
Block a user