Phase 12: Add TanStack React Query

- Install @tanstack/react-query
- Create queryClient with MutationCache for global toast on success/error
- QueryToastBridge component bridges module-level handlers to ToastProvider
- Wrap App in QueryClientProvider
- Create domain hook files: budgets, incomes, outgos, shares, summary
- Update all 5 pages to use hooks; remove inline useEffect/useState fetch logic
- DnD reorder pages use local displayItems state synced from query data
This commit is contained in:
Spencer Twaddle
2026-05-02 16:48:00 -05:00
parent b33ff5079c
commit ae28abdb3e
15 changed files with 448 additions and 222 deletions
+27
View File
@@ -11,6 +11,7 @@
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0", "@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
"@tanstack/react-query": "^5.100.8",
"oidc-client-ts": "^3.5.0", "oidc-client-ts": "^3.5.0",
"react": "^19.2.5", "react": "^19.2.5",
"react-dom": "^19.2.5", "react-dom": "^19.2.5",
@@ -896,6 +897,32 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@tanstack/query-core": {
"version": "5.100.8",
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.100.8.tgz",
"integrity": "sha512-ceYwSFOqjPwET5TA6IOYxzxlGc0ekyH/gfOtWkP0PX43rzX9bxW48Iuw8KAduKCToi4rJAQ6nRy2kAe8gszdmg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@tanstack/react-query": {
"version": "5.100.8",
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.100.8.tgz",
"integrity": "sha512-iNNEekixXU5vtAGKKZX2lx3jTooG5yNY+kv0wSgEdEYG0Mj0JM5bcuQtC35ZAP3nDopT6jciUK3xeX65U7AnfA==",
"license": "MIT",
"dependencies": {
"@tanstack/query-core": "5.100.8"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"react": "^18 || ^19"
}
},
"node_modules/@tybys/wasm-util": { "node_modules/@tybys/wasm-util": {
"version": "0.10.1", "version": "0.10.1",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
+1
View File
@@ -13,6 +13,7 @@
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0", "@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
"@tanstack/react-query": "^5.100.8",
"oidc-client-ts": "^3.5.0", "oidc-client-ts": "^3.5.0",
"react": "^19.2.5", "react": "^19.2.5",
"react-dom": "^19.2.5", "react-dom": "^19.2.5",
+22 -16
View File
@@ -1,10 +1,13 @@
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'; import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { AuthProvider } from 'react-oidc-context'; import { AuthProvider } from 'react-oidc-context';
import { QueryClientProvider } from '@tanstack/react-query';
import { authConfig } from './auth/authConfig'; import { authConfig } from './auth/authConfig';
import { queryClient } from './api/queryClient';
import { TokenSync } from './auth/TokenSync'; import { TokenSync } from './auth/TokenSync';
import { AuthGuard } from './auth/AuthGuard'; import { AuthGuard } from './auth/AuthGuard';
import { ErrorBoundary } from './components/ErrorBoundary'; import { ErrorBoundary } from './components/ErrorBoundary';
import { ToastProvider } from './components/Toast'; import { ToastProvider } from './components/Toast';
import { QueryToastBridge } from './components/QueryToastBridge';
import { CallbackPage } from './pages/CallbackPage'; import { CallbackPage } from './pages/CallbackPage';
import { BudgetsPage } from './pages/BudgetsPage'; import { BudgetsPage } from './pages/BudgetsPage';
import { IncomePage } from './pages/IncomePage'; import { IncomePage } from './pages/IncomePage';
@@ -21,22 +24,25 @@ const onSigninCallback = () => {
function App() { function App() {
return ( return (
<ErrorBoundary> <ErrorBoundary>
<ToastProvider> <QueryClientProvider client={queryClient}>
<AuthProvider {...authConfig} onSigninCallback={onSigninCallback}> <ToastProvider>
<TokenSync /> <QueryToastBridge />
<BrowserRouter> <AuthProvider {...authConfig} onSigninCallback={onSigninCallback}>
<Routes> <TokenSync />
<Route path="/" element={<Navigate to="/budgets" replace />} /> <BrowserRouter>
<Route path="/callback" element={<CallbackPage />} /> <Routes>
<Route path="/budgets" element={<AuthGuard><BudgetsPage /></AuthGuard>} /> <Route path="/" element={<Navigate to="/budgets" replace />} />
<Route path="/budgets/:id/income" element={<AuthGuard><IncomePage /></AuthGuard>} /> <Route path="/callback" element={<CallbackPage />} />
<Route path="/budgets/:id/outgo" element={<AuthGuard><OutgoPage /></AuthGuard>} /> <Route path="/budgets" element={<AuthGuard><BudgetsPage /></AuthGuard>} />
<Route path="/budgets/:id/summary" element={<AuthGuard><SummaryPage /></AuthGuard>} /> <Route path="/budgets/:id/income" element={<AuthGuard><IncomePage /></AuthGuard>} />
<Route path="/budgets/:id/settings" element={<AuthGuard><SettingsPage /></AuthGuard>} /> <Route path="/budgets/:id/outgo" element={<AuthGuard><OutgoPage /></AuthGuard>} />
</Routes> <Route path="/budgets/:id/summary" element={<AuthGuard><SummaryPage /></AuthGuard>} />
</BrowserRouter> <Route path="/budgets/:id/settings" element={<AuthGuard><SettingsPage /></AuthGuard>} />
</AuthProvider> </Routes>
</ToastProvider> </BrowserRouter>
</AuthProvider>
</ToastProvider>
</QueryClientProvider>
</ErrorBoundary> </ErrorBoundary>
); );
} }
+47
View File
@@ -0,0 +1,47 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { api } from './client';
import type { BudgetDto } from '../types/index';
interface CreateBudgetRequest { name: string; }
interface UpdateBudgetRequest { name: string; }
export function useBudgets() {
return useQuery({
queryKey: ['budgets'],
queryFn: () => api.get<BudgetDto[]>('/api/budgets'),
});
}
export function useBudget(id: string) {
return useQuery({
queryKey: ['budgets', id],
queryFn: () => api.get<BudgetDto>(`/api/budgets/${id}`),
});
}
export function useCreateBudget() {
const qc = useQueryClient();
return useMutation({
mutationFn: (req: CreateBudgetRequest) => api.post<BudgetDto>('/api/budgets', req),
onSuccess: () => qc.invalidateQueries({ queryKey: ['budgets'] }),
meta: { successMessage: 'Budget created', errorMessage: 'Failed to create budget' },
});
}
export function useUpdateBudget(id: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: (req: UpdateBudgetRequest) => api.put<BudgetDto>(`/api/budgets/${id}`, req),
onSuccess: () => qc.invalidateQueries({ queryKey: ['budgets', id] }),
meta: { successMessage: 'Budget saved', errorMessage: 'Failed to save budget' },
});
}
export function useDeleteBudget() {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: string) => api.delete(`/api/budgets/${id}`),
onSuccess: () => qc.invalidateQueries({ queryKey: ['budgets'] }),
meta: { errorMessage: 'Failed to delete budget' },
});
}
+51
View File
@@ -0,0 +1,51 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { api } from './client';
import type { IncomeDto, Frequency } from '../types/index';
interface CreateIncomeRequest { name: string; frequency: Frequency; amount: number; }
interface UpdateIncomeRequest { name: string; frequency: Frequency; amount: number; }
interface ReorderRequest { orderedIds: string[]; }
export function useIncomes(budgetId: string) {
return useQuery({
queryKey: ['budgets', budgetId, 'incomes'],
queryFn: () => api.get<IncomeDto[]>(`/api/budgets/${budgetId}/incomes`),
});
}
export function useCreateIncome(budgetId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: (req: CreateIncomeRequest) => api.post<IncomeDto>(`/api/budgets/${budgetId}/incomes`, req),
onSuccess: () => qc.invalidateQueries({ queryKey: ['budgets', budgetId, 'incomes'] }),
meta: { errorMessage: 'Failed to add income' },
});
}
export function useUpdateIncome(budgetId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ id, ...req }: UpdateIncomeRequest & { id: string }) =>
api.put<IncomeDto>(`/api/budgets/${budgetId}/incomes/${id}`, req),
onSuccess: () => qc.invalidateQueries({ queryKey: ['budgets', budgetId, 'incomes'] }),
meta: { errorMessage: 'Failed to save income' },
});
}
export function useDeleteIncome(budgetId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: string) => api.delete(`/api/budgets/${budgetId}/incomes/${id}`),
onSuccess: () => qc.invalidateQueries({ queryKey: ['budgets', budgetId, 'incomes'] }),
meta: { errorMessage: 'Failed to delete income' },
});
}
export function useReorderIncomes(budgetId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: (req: ReorderRequest) => api.put<void>(`/api/budgets/${budgetId}/incomes/order`, req),
onSuccess: () => qc.invalidateQueries({ queryKey: ['budgets', budgetId, 'incomes'] }),
meta: { errorMessage: 'Failed to save order' },
});
}
+68
View File
@@ -0,0 +1,68 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { api } from './client';
import type { OutgoDto, Frequency, OutgoType } from '../types/index';
interface CreateOutgoRequest {
name: string; category: string | null; type: OutgoType;
frequency: Frequency; amount: number; paymentSource: string | null; notes: string | null;
}
interface UpdateOutgoRequest extends CreateOutgoRequest { id: string; }
interface ReorderRequest { orderedIds: string[]; }
export function useOutgos(budgetId: string) {
return useQuery({
queryKey: ['budgets', budgetId, 'outgos'],
queryFn: () => api.get<OutgoDto[]>(`/api/budgets/${budgetId}/outgos`),
});
}
export function useCategories(budgetId: string) {
return useQuery({
queryKey: ['budgets', budgetId, 'outgos', 'categories'],
queryFn: () => api.get<string[]>(`/api/budgets/${budgetId}/outgos/categories`),
});
}
export function usePaymentSources(budgetId: string) {
return useQuery({
queryKey: ['budgets', budgetId, 'outgos', 'payment-sources'],
queryFn: () => api.get<string[]>(`/api/budgets/${budgetId}/outgos/payment-sources`),
});
}
export function useCreateOutgo(budgetId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: (req: CreateOutgoRequest) => api.post<OutgoDto>(`/api/budgets/${budgetId}/outgos`, req),
onSuccess: () => qc.invalidateQueries({ queryKey: ['budgets', budgetId, 'outgos'] }),
meta: { errorMessage: 'Failed to add outgo' },
});
}
export function useUpdateOutgo(budgetId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ id, ...req }: UpdateOutgoRequest) =>
api.put<OutgoDto>(`/api/budgets/${budgetId}/outgos/${id}`, req),
onSuccess: () => qc.invalidateQueries({ queryKey: ['budgets', budgetId, 'outgos'] }),
meta: { errorMessage: 'Failed to save outgo' },
});
}
export function useDeleteOutgo(budgetId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: string) => api.delete(`/api/budgets/${budgetId}/outgos/${id}`),
onSuccess: () => qc.invalidateQueries({ queryKey: ['budgets', budgetId, 'outgos'] }),
meta: { errorMessage: 'Failed to delete outgo' },
});
}
export function useReorderOutgos(budgetId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: (req: ReorderRequest) => api.put<void>(`/api/budgets/${budgetId}/outgos/order`, req),
onSuccess: () => qc.invalidateQueries({ queryKey: ['budgets', budgetId, 'outgos'] }),
meta: { errorMessage: 'Failed to save order' },
});
}
+27
View File
@@ -0,0 +1,27 @@
import { MutationCache, QueryClient } from '@tanstack/react-query';
declare module '@tanstack/react-query' {
interface Register {
mutationMeta: {
successMessage?: string;
errorMessage?: string;
};
}
}
let toastHandlers: { showError: (msg: string) => void; showInfo: (msg: string) => void } | null = null;
export function setToastHandlers(handlers: typeof toastHandlers) {
toastHandlers = handlers;
}
export const queryClient = new QueryClient({
mutationCache: new MutationCache({
onSuccess: (_data, _vars, _ctx, mutation) => {
if (mutation.meta?.successMessage) toastHandlers?.showInfo(mutation.meta.successMessage);
},
onError: (_err, _vars, _ctx, mutation) => {
toastHandlers?.showError(mutation.meta?.errorMessage ?? 'Something went wrong');
},
}),
});
+41
View File
@@ -0,0 +1,41 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { api } from './client';
import type { ShareDto, SharePermission } from '../types/index';
interface AddShareRequest { email: string; permission: SharePermission; }
interface UpdateShareRequest { permission: SharePermission; }
export function useShares(budgetId: string) {
return useQuery({
queryKey: ['budgets', budgetId, 'shares'],
queryFn: () => api.get<ShareDto[]>(`/api/budgets/${budgetId}/shares`),
});
}
export function useAddShare(budgetId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: (req: AddShareRequest) => api.post<ShareDto>(`/api/budgets/${budgetId}/shares`, req),
onSuccess: () => qc.invalidateQueries({ queryKey: ['budgets', budgetId, 'shares'] }),
meta: { successMessage: 'Share invitation sent', errorMessage: 'Failed to add share' },
});
}
export function useUpdateShare(budgetId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ id, ...req }: UpdateShareRequest & { id: string }) =>
api.put<ShareDto>(`/api/budgets/${budgetId}/shares/${id}`, req),
onSuccess: () => qc.invalidateQueries({ queryKey: ['budgets', budgetId, 'shares'] }),
meta: { errorMessage: 'Failed to update permission' },
});
}
export function useRevokeShare(budgetId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: string) => api.delete(`/api/budgets/${budgetId}/shares/${id}`),
onSuccess: () => qc.invalidateQueries({ queryKey: ['budgets', budgetId, 'shares'] }),
meta: { errorMessage: 'Failed to revoke share' },
});
}
+23
View File
@@ -0,0 +1,23 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { api } from './client';
import type { SummaryDto } from '../types/index';
export function useSummary(budgetId: string) {
return useQuery({
queryKey: ['budgets', budgetId, 'summary'],
queryFn: () => api.get<SummaryDto>(`/api/budgets/${budgetId}/summary`),
});
}
export function useUpdateTaxRate(budgetId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: (effectiveTaxRate: number) =>
api.put<void>(`/api/budgets/${budgetId}/summary/tax-rate`, { effectiveTaxRate }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['budgets', budgetId, 'summary'] });
qc.invalidateQueries({ queryKey: ['budgets', budgetId] });
},
meta: { successMessage: 'Tax rate saved', errorMessage: 'Failed to save tax rate' },
});
}
@@ -0,0 +1,11 @@
import { useEffect } from 'react';
import { useToast } from './Toast';
import { setToastHandlers } from '../api/queryClient';
export function QueryToastBridge() {
const { showError, showInfo } = useToast();
useEffect(() => {
setToastHandlers({ showError, showInfo });
}, [showError, showInfo]);
return null;
}
+5 -10
View File
@@ -1,21 +1,16 @@
import { useEffect, useState } from 'react'; import { useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import type { BudgetDto } from '../types'; import { useBudgets, useCreateBudget } from '../api/budgets';
import { api } from '../api/client';
export function BudgetsPage() { export function BudgetsPage() {
const [budgets, setBudgets] = useState<BudgetDto[]>([]);
const [newName, setNewName] = useState(''); const [newName, setNewName] = useState('');
const navigate = useNavigate(); const navigate = useNavigate();
const { data: budgets = [] } = useBudgets();
useEffect(() => { const createBudget = useCreateBudget();
api.get<BudgetDto[]>('/api/budgets').then(setBudgets);
}, []);
const handleCreate = async () => { const handleCreate = async () => {
if (!newName.trim()) return; if (!newName.trim()) return;
const created = await api.post<BudgetDto>('/api/budgets', { name: newName.trim() }); const created = await createBudget.mutateAsync({ name: newName.trim() });
setBudgets(prev => [...prev, created]);
setNewName(''); setNewName('');
navigate(`/budgets/${created.id}/income`); navigate(`/budgets/${created.id}/income`);
}; };
+26 -47
View File
@@ -1,6 +1,5 @@
import { useEffect, useState } from 'react'; import { useState, useEffect } from 'react';
import { useParams } from 'react-router-dom'; import { useParams } from 'react-router-dom';
import { useToast } from '../components/Toast';
import { LoadingSkeleton } from '../components/LoadingSkeleton'; import { LoadingSkeleton } from '../components/LoadingSkeleton';
import { import {
DndContext, DndContext,
@@ -17,11 +16,11 @@ import {
arrayMove, arrayMove,
} from '@dnd-kit/sortable'; } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities'; import { CSS } from '@dnd-kit/utilities';
import type { IncomeDto, Frequency } from '../types'; import type { IncomeDto, Frequency } from '../types/index';
import { api } from '../api/client';
import { FrequencySelect } from '../components/FrequencySelect'; import { FrequencySelect } from '../components/FrequencySelect';
import { MoneyDisplay } from '../components/MoneyDisplay'; import { MoneyDisplay } from '../components/MoneyDisplay';
import { BudgetNav } from '../components/BudgetNav'; import { BudgetNav } from '../components/BudgetNav';
import { useIncomes, useCreateIncome, useUpdateIncome, useDeleteIncome, useReorderIncomes } from '../api/incomes';
interface EditState { interface EditState {
name: string; name: string;
@@ -88,61 +87,41 @@ function SortableRow({
export function IncomePage() { export function IncomePage() {
const { id: budgetId } = useParams<{ id: string }>(); const { id: budgetId } = useParams<{ id: string }>();
const [incomes, setIncomes] = useState<IncomeDto[]>([]);
const [loading, setLoading] = useState(true);
const { showError } = useToast();
const sensors = useSensors(useSensor(PointerSensor)); const sensors = useSensors(useSensor(PointerSensor));
useEffect(() => { const { data: incomes = [], isLoading } = useIncomes(budgetId!);
if (!budgetId) return; const [displayItems, setDisplayItems] = useState<IncomeDto[]>([]);
setLoading(true); useEffect(() => { setDisplayItems(incomes); }, [incomes]);
api.get<IncomeDto[]>(`/api/budgets/${budgetId}/incomes`)
.then(setIncomes)
.catch(e => showError(String(e)))
.finally(() => setLoading(false));
}, [budgetId]);
const handleSave = async (id: string, edit: EditState) => { const updateIncome = useUpdateIncome(budgetId!);
try { const deleteIncome = useDeleteIncome(budgetId!);
const updated = await api.put<IncomeDto>(`/api/budgets/${budgetId}/incomes/${id}`, { const createIncome = useCreateIncome(budgetId!);
name: edit.name, const reorderIncomes = useReorderIncomes(budgetId!);
frequency: edit.frequency,
amount: parseFloat(edit.amount), const handleSave = (id: string, edit: EditState) => {
}); updateIncome.mutate({ id, name: edit.name, frequency: edit.frequency, amount: parseFloat(edit.amount) });
setIncomes(prev => prev.map(i => i.id === id ? updated : i));
} catch (e) { showError(String(e)); }
}; };
const handleDelete = async (id: string) => { const handleDelete = (id: string) => {
if (!confirm('Delete this income row?')) return; if (!confirm('Delete this income row?')) return;
try { deleteIncome.mutate(id);
await api.delete(`/api/budgets/${budgetId}/incomes/${id}`);
setIncomes(prev => prev.filter(i => i.id !== id));
} catch (e) { showError(String(e)); }
}; };
const handleAdd = async () => { const handleAdd = () => {
const created = await api.post<IncomeDto>(`/api/budgets/${budgetId}/incomes`, { createIncome.mutate({ name: 'New Income', frequency: 'Monthly', amount: 0 });
name: 'New Income',
frequency: 'Monthly',
amount: 0,
});
setIncomes(prev => [...prev, created]);
}; };
const handleDragEnd = async (event: DragEndEvent) => { const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event; const { active, over } = event;
if (!over || active.id === over.id) return; if (!over || active.id === over.id) return;
const oldIdx = incomes.findIndex(i => i.id === active.id); const oldIdx = displayItems.findIndex(i => i.id === active.id);
const newIdx = incomes.findIndex(i => i.id === over.id); const newIdx = displayItems.findIndex(i => i.id === over.id);
const reordered = arrayMove(incomes, oldIdx, newIdx); const reordered = arrayMove(displayItems, oldIdx, newIdx);
setIncomes(reordered); setDisplayItems(reordered);
await api.put(`/api/budgets/${budgetId}/incomes/order`, { reorderIncomes.mutate({ orderedIds: reordered.map(i => i.id) });
orderedIds: reordered.map(i => i.id),
});
}; };
if (loading) return <><BudgetNav /><LoadingSkeleton rows={5} cols={6} /></>; if (isLoading) return <><BudgetNav /><LoadingSkeleton rows={5} cols={6} /></>;
return ( return (
<div> <div>
@@ -161,9 +140,9 @@ export function IncomePage() {
</tr> </tr>
</thead> </thead>
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}> <DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext items={incomes.map(i => i.id)} strategy={verticalListSortingStrategy}> <SortableContext items={displayItems.map(i => i.id)} strategy={verticalListSortingStrategy}>
<tbody> <tbody>
{incomes.map(income => ( {displayItems.map(income => (
<SortableRow <SortableRow
key={income.id} key={income.id}
income={income} income={income}
+55 -76
View File
@@ -1,6 +1,5 @@
import { useEffect, useState } from 'react'; import { useState, useEffect } from 'react';
import { useParams } from 'react-router-dom'; import { useParams } from 'react-router-dom';
import { useToast } from '../components/Toast';
import { LoadingSkeleton } from '../components/LoadingSkeleton'; import { LoadingSkeleton } from '../components/LoadingSkeleton';
import { import {
DndContext, DndContext,
@@ -17,12 +16,15 @@ import {
arrayMove, arrayMove,
} from '@dnd-kit/sortable'; } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities'; import { CSS } from '@dnd-kit/utilities';
import type { OutgoDto, Frequency, OutgoType } from '../types'; import type { OutgoDto, Frequency, OutgoType } from '../types/index';
import { api } from '../api/client';
import { FrequencySelect } from '../components/FrequencySelect'; import { FrequencySelect } from '../components/FrequencySelect';
import { MoneyDisplay } from '../components/MoneyDisplay'; import { MoneyDisplay } from '../components/MoneyDisplay';
import { AutocompleteInput } from '../components/AutocompleteInput'; import { AutocompleteInput } from '../components/AutocompleteInput';
import { BudgetNav } from '../components/BudgetNav'; import { BudgetNav } from '../components/BudgetNav';
import {
useOutgos, useCategories, usePaymentSources,
useCreateOutgo, useUpdateOutgo, useDeleteOutgo, useReorderOutgos,
} from '../api/outgos';
const OUTGO_TYPES: OutgoType[] = ['Need', 'Want', 'Save']; const OUTGO_TYPES: OutgoType[] = ['Need', 'Want', 'Save'];
@@ -129,83 +131,60 @@ function SortableRow({
export function OutgoPage() { export function OutgoPage() {
const { id: budgetId } = useParams<{ id: string }>(); const { id: budgetId } = useParams<{ id: string }>();
const [outgos, setOutgos] = useState<OutgoDto[]>([]);
const [categories, setCategories] = useState<string[]>([]);
const [paymentSources, setPaymentSources] = useState<string[]>([]);
const [loading, setLoading] = useState(true);
const { showError } = useToast();
const sensors = useSensors(useSensor(PointerSensor)); const sensors = useSensors(useSensor(PointerSensor));
useEffect(() => { const { data: outgos = [], isLoading } = useOutgos(budgetId!);
if (!budgetId) return; const { data: categories = [] } = useCategories(budgetId!);
setLoading(true); const { data: paymentSources = [] } = usePaymentSources(budgetId!);
Promise.all([ const [displayItems, setDisplayItems] = useState<OutgoDto[]>([]);
api.get<OutgoDto[]>(`/api/budgets/${budgetId}/outgos`), useEffect(() => { setDisplayItems(outgos); }, [outgos]);
api.get<string[]>(`/api/budgets/${budgetId}/outgos/categories`),
api.get<string[]>(`/api/budgets/${budgetId}/outgos/payment-sources`),
]).then(([o, c, p]) => { setOutgos(o); setCategories(c); setPaymentSources(p); })
.catch(e => showError(String(e)))
.finally(() => setLoading(false));
}, [budgetId]);
const refreshSuggestions = () => { const updateOutgo = useUpdateOutgo(budgetId!);
if (!budgetId) return; const deleteOutgo = useDeleteOutgo(budgetId!);
api.get<string[]>(`/api/budgets/${budgetId}/outgos/categories`).then(setCategories); const createOutgo = useCreateOutgo(budgetId!);
api.get<string[]>(`/api/budgets/${budgetId}/outgos/payment-sources`).then(setPaymentSources); const reorderOutgos = useReorderOutgos(budgetId!);
};
const handleSave = async (id: string, edit: EditState) => { const handleSave = (id: string, edit: EditState) => {
try { updateOutgo.mutate({
const updated = await api.put<OutgoDto>(`/api/budgets/${budgetId}/outgos/${id}`, { id,
name: edit.name, name: edit.name,
category: edit.category || null, category: edit.category || null,
type: edit.type, type: edit.type,
frequency: edit.frequency, frequency: edit.frequency,
amount: parseFloat(edit.amount), amount: parseFloat(edit.amount),
paymentSource: edit.paymentSource || null, paymentSource: edit.paymentSource || null,
notes: edit.notes || null, notes: edit.notes || null,
});
setOutgos(prev => prev.map(o => o.id === id ? updated : o));
refreshSuggestions();
} catch (e) { showError(String(e)); }
};
const handleDelete = async (id: string) => {
if (!confirm('Delete this outgo row?')) return;
try {
await api.delete(`/api/budgets/${budgetId}/outgos/${id}`);
setOutgos(prev => prev.filter(o => o.id !== id));
} catch (e) { showError(String(e)); }
};
const handleAdd = async () => {
try {
const created = await api.post<OutgoDto>(`/api/budgets/${budgetId}/outgos`, {
name: 'New Outgo',
category: null,
type: 'Need',
frequency: 'Monthly',
amount: 0,
paymentSource: null,
notes: null,
});
setOutgos(prev => [...prev, created]);
} catch (e) { showError(String(e)); }
};
const handleDragEnd = async (event: DragEndEvent) => {
const { active, over } = event;
if (!over || active.id === over.id) return;
const oldIdx = outgos.findIndex(o => o.id === active.id);
const newIdx = outgos.findIndex(o => o.id === over.id);
const reordered = arrayMove(outgos, oldIdx, newIdx);
setOutgos(reordered);
await api.put(`/api/budgets/${budgetId}/outgos/order`, {
orderedIds: reordered.map(o => o.id),
}); });
}; };
if (loading) return <><BudgetNav /><LoadingSkeleton rows={5} cols={9} /></>; const handleDelete = (id: string) => {
if (!confirm('Delete this outgo row?')) return;
deleteOutgo.mutate(id);
};
const handleAdd = () => {
createOutgo.mutate({
name: 'New Outgo',
category: null,
type: 'Need',
frequency: 'Monthly',
amount: 0,
paymentSource: null,
notes: null,
});
};
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (!over || active.id === over.id) return;
const oldIdx = displayItems.findIndex(o => o.id === active.id);
const newIdx = displayItems.findIndex(o => o.id === over.id);
const reordered = arrayMove(displayItems, oldIdx, newIdx);
setDisplayItems(reordered);
reorderOutgos.mutate({ orderedIds: reordered.map(o => o.id) });
};
if (isLoading) return <><BudgetNav /><LoadingSkeleton rows={5} cols={9} /></>;
return ( return (
<div> <div>
@@ -230,9 +209,9 @@ export function OutgoPage() {
</tr> </tr>
</thead> </thead>
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}> <DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext items={outgos.map(o => o.id)} strategy={verticalListSortingStrategy}> <SortableContext items={displayItems.map(o => o.id)} strategy={verticalListSortingStrategy}>
<tbody> <tbody>
{outgos.map(outgo => ( {displayItems.map(outgo => (
<SortableRow <SortableRow
key={outgo.id} key={outgo.id}
outgo={outgo} outgo={outgo}
+32 -52
View File
@@ -1,69 +1,49 @@
import { useEffect, useState } from 'react'; import { useState, useEffect } from 'react';
import { useParams } from 'react-router-dom'; import { useParams } from 'react-router-dom';
import type { BudgetDto, ShareDto, SharePermission } from '../types'; import type { SharePermission } from '../types/index';
import { api } from '../api/client';
import { BudgetNav } from '../components/BudgetNav'; import { BudgetNav } from '../components/BudgetNav';
import { useBudget, useUpdateBudget } from '../api/budgets';
import { useUpdateTaxRate } from '../api/summary';
import { useShares, useAddShare, useUpdateShare, useRevokeShare } from '../api/shares';
export function SettingsPage() { export function SettingsPage() {
const { id: budgetId } = useParams<{ id: string }>(); const { id: budgetId } = useParams<{ id: string }>();
const [budget, setBudget] = useState<BudgetDto | null>(null); const { data: budget } = useBudget(budgetId!);
const [shares, setShares] = useState<ShareDto[]>([]); const { data: shares = [] } = useShares(budgetId!);
const [nameInput, setNameInput] = useState(''); const [nameInput, setNameInput] = useState('');
const [taxInput, setTaxInput] = useState(''); const [taxInput, setTaxInput] = useState('');
const [shareEmail, setShareEmail] = useState(''); const [shareEmail, setShareEmail] = useState('');
const [sharePermission, setSharePermission] = useState<SharePermission>('View'); const [sharePermission, setSharePermission] = useState<SharePermission>('View');
const [saving, setSaving] = useState(false);
useEffect(() => { useEffect(() => {
if (!budgetId) return; if (budget) {
api.get<BudgetDto>(`/api/budgets/${budgetId}`).then(b => { setNameInput(budget.name);
setBudget(b); setTaxInput(String(Math.round(budget.effectiveTaxRate * 100)));
setNameInput(b.name); }
setTaxInput(String(Math.round(b.effectiveTaxRate * 100))); }, [budget]);
});
api.get<ShareDto[]>(`/api/budgets/${budgetId}/shares`).then(setShares);
}, [budgetId]);
const saveName = async () => { const updateBudget = useUpdateBudget(budgetId!);
if (!budgetId || !nameInput.trim()) return; const updateTaxRate = useUpdateTaxRate(budgetId!);
setSaving(true); const addShare = useAddShare(budgetId!);
const updated = await api.put<BudgetDto>(`/api/budgets/${budgetId}`, { name: nameInput.trim() }); const updateShare = useUpdateShare(budgetId!);
setBudget(updated); const revokeShare = useRevokeShare(budgetId!);
setSaving(false);
const saveName = () => {
if (!nameInput.trim()) return;
updateBudget.mutate({ name: nameInput.trim() });
}; };
const saveTaxRate = async () => { const saveTaxRate = () => {
if (!budgetId) return; updateTaxRate.mutate(parseFloat(taxInput) / 100);
setSaving(true);
const rate = parseFloat(taxInput) / 100;
await api.put(`/api/budgets/${budgetId}/summary/tax-rate`, { effectiveTaxRate: rate });
const updated = await api.get<BudgetDto>(`/api/budgets/${budgetId}`);
setBudget(updated);
setSaving(false);
}; };
const addShare = async () => { const handleAddShare = () => {
if (!budgetId || !shareEmail.trim()) return; if (!shareEmail.trim()) return;
const share = await api.post<ShareDto>(`/api/budgets/${budgetId}/shares`, { addShare.mutate({ email: shareEmail.trim(), permission: sharePermission });
email: shareEmail.trim(),
permission: sharePermission,
});
setShares(prev => [...prev, share]);
setShareEmail(''); setShareEmail('');
}; };
const updatePermission = async (shareId: string, permission: SharePermission) => {
if (!budgetId) return;
const updated = await api.put<ShareDto>(`/api/budgets/${budgetId}/shares/${shareId}`, { permission });
setShares(prev => prev.map(s => s.id === shareId ? updated : s));
};
const revokeShare = async (shareId: string) => {
if (!budgetId) return;
await api.delete(`/api/budgets/${budgetId}/shares/${shareId}`);
setShares(prev => prev.filter(s => s.id !== shareId));
};
if (!budget) return <div>Loading...</div>; if (!budget) return <div>Loading...</div>;
return ( return (
@@ -74,7 +54,7 @@ export function SettingsPage() {
<section style={{ marginBottom: '2rem' }}> <section style={{ marginBottom: '2rem' }}>
<h2>Rename Budget</h2> <h2>Rename Budget</h2>
<input value={nameInput} onChange={e => setNameInput(e.target.value)} /> <input value={nameInput} onChange={e => setNameInput(e.target.value)} />
<button onClick={saveName} disabled={saving} style={{ marginLeft: '8px' }}>Save</button> <button onClick={saveName} disabled={updateBudget.isPending} style={{ marginLeft: '8px' }}>Save</button>
</section> </section>
<section style={{ marginBottom: '2rem' }}> <section style={{ marginBottom: '2rem' }}>
@@ -89,7 +69,7 @@ export function SettingsPage() {
onChange={e => setTaxInput(e.target.value)} onChange={e => setTaxInput(e.target.value)}
style={{ width: '60px' }} style={{ width: '60px' }}
/> />
<button onClick={saveTaxRate} disabled={saving} style={{ marginLeft: '8px' }}>Save</button> <button onClick={saveTaxRate} disabled={updateTaxRate.isPending} style={{ marginLeft: '8px' }}>Save</button>
</label> </label>
</section> </section>
@@ -111,14 +91,14 @@ export function SettingsPage() {
<td> <td>
<select <select
value={s.permission} value={s.permission}
onChange={e => updatePermission(s.id, e.target.value as SharePermission)} onChange={e => updateShare.mutate({ id: s.id, permission: e.target.value as SharePermission })}
> >
<option value="View">View</option> <option value="View">View</option>
<option value="Edit">Edit</option> <option value="Edit">Edit</option>
</select> </select>
</td> </td>
<td>{s.isPending ? <em>Pending</em> : 'Active'}</td> <td>{s.isPending ? <em>Pending</em> : 'Active'}</td>
<td><button onClick={() => revokeShare(s.id)}>Revoke</button></td> <td><button onClick={() => revokeShare.mutate(s.id)}>Revoke</button></td>
</tr> </tr>
))} ))}
</tbody> </tbody>
@@ -134,7 +114,7 @@ export function SettingsPage() {
<option value="View">View</option> <option value="View">View</option>
<option value="Edit">Edit</option> <option value="Edit">Edit</option>
</select> </select>
<button onClick={addShare}>Add Share</button> <button onClick={handleAddShare}>Add Share</button>
</div> </div>
</section> </section>
</div> </div>
+12 -21
View File
@@ -1,34 +1,25 @@
import { useEffect, useState } from 'react'; import { useState, useEffect } from 'react';
import { useParams } from 'react-router-dom'; import { useParams } from 'react-router-dom';
import type { SummaryDto } from '../types';
import { api } from '../api/client';
import { MoneyDisplay } from '../components/MoneyDisplay'; import { MoneyDisplay } from '../components/MoneyDisplay';
import { BudgetNav } from '../components/BudgetNav'; import { BudgetNav } from '../components/BudgetNav';
import { useSummary, useUpdateTaxRate } from '../api/summary';
const fmt = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }); const fmt = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' });
export function SummaryPage() { export function SummaryPage() {
const { id: budgetId } = useParams<{ id: string }>(); const { id: budgetId } = useParams<{ id: string }>();
const [summary, setSummary] = useState<SummaryDto | null>(null); const { data: summary } = useSummary(budgetId!);
const [taxRateInput, setTaxRateInput] = useState(''); const [taxRateInput, setTaxRateInput] = useState('');
const [savingTax, setSavingTax] = useState(false); const updateTaxRate = useUpdateTaxRate(budgetId!);
useEffect(() => { useEffect(() => {
if (!budgetId) return; if (summary) {
api.get<SummaryDto>(`/api/budgets/${budgetId}/summary`).then(s => { setTaxRateInput(String(Math.round(summary.preTaxIncome.effectiveTaxRate * 100)));
setSummary(s); }
setTaxRateInput(String(Math.round(s.preTaxIncome.effectiveTaxRate * 100))); }, [summary]);
});
}, [budgetId]);
const saveTaxRate = async () => { const saveTaxRate = () => {
if (!budgetId || !summary) return; updateTaxRate.mutate(parseFloat(taxRateInput) / 100);
setSavingTax(true);
const rate = parseFloat(taxRateInput) / 100;
await api.put(`/api/budgets/${budgetId}/summary/tax-rate`, { effectiveTaxRate: rate });
const updated = await api.get<SummaryDto>(`/api/budgets/${budgetId}/summary`);
setSummary(updated);
setSavingTax(false);
}; };
if (!summary) return <div>Loading...</div>; if (!summary) return <div>Loading...</div>;
@@ -96,8 +87,8 @@ export function SummaryPage() {
onChange={e => setTaxRateInput(e.target.value)} onChange={e => setTaxRateInput(e.target.value)}
style={{ width: '60px' }} style={{ width: '60px' }}
/> />
<button onClick={saveTaxRate} disabled={savingTax} style={{ marginLeft: '8px' }}> <button onClick={saveTaxRate} disabled={updateTaxRate.isPending} style={{ marginLeft: '8px' }}>
{savingTax ? 'Saving…' : 'Save'} {updateTaxRate.isPending ? 'Saving…' : 'Save'}
</button> </button>
</label> </label>
</div> </div>