From ae28abdb3e9ebce707b8f9e18425764f4cb3c2d1 Mon Sep 17 00:00:00 2001 From: Spencer Twaddle <7374698+stwaddle@users.noreply.github.com> Date: Sat, 2 May 2026 16:48:00 -0500 Subject: [PATCH] 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 --- src/Budget.Client/package-lock.json | 27 ++++ src/Budget.Client/package.json | 1 + src/Budget.Client/src/App.tsx | 38 ++--- src/Budget.Client/src/api/budgets.ts | 47 +++++++ src/Budget.Client/src/api/incomes.ts | 51 +++++++ src/Budget.Client/src/api/outgos.ts | 68 +++++++++ src/Budget.Client/src/api/queryClient.ts | 27 ++++ src/Budget.Client/src/api/shares.ts | 41 ++++++ src/Budget.Client/src/api/summary.ts | 23 +++ .../src/components/QueryToastBridge.tsx | 11 ++ src/Budget.Client/src/pages/BudgetsPage.tsx | 15 +- src/Budget.Client/src/pages/IncomePage.tsx | 73 ++++------ src/Budget.Client/src/pages/OutgoPage.tsx | 131 ++++++++---------- src/Budget.Client/src/pages/SettingsPage.tsx | 84 +++++------ src/Budget.Client/src/pages/SummaryPage.tsx | 33 ++--- 15 files changed, 448 insertions(+), 222 deletions(-) create mode 100644 src/Budget.Client/src/api/budgets.ts create mode 100644 src/Budget.Client/src/api/incomes.ts create mode 100644 src/Budget.Client/src/api/outgos.ts create mode 100644 src/Budget.Client/src/api/queryClient.ts create mode 100644 src/Budget.Client/src/api/shares.ts create mode 100644 src/Budget.Client/src/api/summary.ts create mode 100644 src/Budget.Client/src/components/QueryToastBridge.tsx diff --git a/src/Budget.Client/package-lock.json b/src/Budget.Client/package-lock.json index 04ac015..9f2c94e 100644 --- a/src/Budget.Client/package-lock.json +++ b/src/Budget.Client/package-lock.json @@ -11,6 +11,7 @@ "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", + "@tanstack/react-query": "^5.100.8", "oidc-client-ts": "^3.5.0", "react": "^19.2.5", "react-dom": "^19.2.5", @@ -896,6 +897,32 @@ "dev": true, "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": { "version": "0.10.1", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", diff --git a/src/Budget.Client/package.json b/src/Budget.Client/package.json index 5cd4cfd..efb49fe 100644 --- a/src/Budget.Client/package.json +++ b/src/Budget.Client/package.json @@ -13,6 +13,7 @@ "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", + "@tanstack/react-query": "^5.100.8", "oidc-client-ts": "^3.5.0", "react": "^19.2.5", "react-dom": "^19.2.5", diff --git a/src/Budget.Client/src/App.tsx b/src/Budget.Client/src/App.tsx index 3756eed..0d33899 100644 --- a/src/Budget.Client/src/App.tsx +++ b/src/Budget.Client/src/App.tsx @@ -1,10 +1,13 @@ import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'; import { AuthProvider } from 'react-oidc-context'; +import { QueryClientProvider } from '@tanstack/react-query'; import { authConfig } from './auth/authConfig'; +import { queryClient } from './api/queryClient'; import { TokenSync } from './auth/TokenSync'; import { AuthGuard } from './auth/AuthGuard'; import { ErrorBoundary } from './components/ErrorBoundary'; import { ToastProvider } from './components/Toast'; +import { QueryToastBridge } from './components/QueryToastBridge'; import { CallbackPage } from './pages/CallbackPage'; import { BudgetsPage } from './pages/BudgetsPage'; import { IncomePage } from './pages/IncomePage'; @@ -21,22 +24,25 @@ const onSigninCallback = () => { function App() { return ( - - - - - - } /> - } /> - } /> - } /> - } /> - } /> - } /> - - - - + + + + + + + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + + + ); } diff --git a/src/Budget.Client/src/api/budgets.ts b/src/Budget.Client/src/api/budgets.ts new file mode 100644 index 0000000..099f625 --- /dev/null +++ b/src/Budget.Client/src/api/budgets.ts @@ -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('/api/budgets'), + }); +} + +export function useBudget(id: string) { + return useQuery({ + queryKey: ['budgets', id], + queryFn: () => api.get(`/api/budgets/${id}`), + }); +} + +export function useCreateBudget() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (req: CreateBudgetRequest) => api.post('/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(`/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' }, + }); +} diff --git a/src/Budget.Client/src/api/incomes.ts b/src/Budget.Client/src/api/incomes.ts new file mode 100644 index 0000000..0442c8d --- /dev/null +++ b/src/Budget.Client/src/api/incomes.ts @@ -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(`/api/budgets/${budgetId}/incomes`), + }); +} + +export function useCreateIncome(budgetId: string) { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (req: CreateIncomeRequest) => api.post(`/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(`/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(`/api/budgets/${budgetId}/incomes/order`, req), + onSuccess: () => qc.invalidateQueries({ queryKey: ['budgets', budgetId, 'incomes'] }), + meta: { errorMessage: 'Failed to save order' }, + }); +} diff --git a/src/Budget.Client/src/api/outgos.ts b/src/Budget.Client/src/api/outgos.ts new file mode 100644 index 0000000..1219276 --- /dev/null +++ b/src/Budget.Client/src/api/outgos.ts @@ -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(`/api/budgets/${budgetId}/outgos`), + }); +} + +export function useCategories(budgetId: string) { + return useQuery({ + queryKey: ['budgets', budgetId, 'outgos', 'categories'], + queryFn: () => api.get(`/api/budgets/${budgetId}/outgos/categories`), + }); +} + +export function usePaymentSources(budgetId: string) { + return useQuery({ + queryKey: ['budgets', budgetId, 'outgos', 'payment-sources'], + queryFn: () => api.get(`/api/budgets/${budgetId}/outgos/payment-sources`), + }); +} + +export function useCreateOutgo(budgetId: string) { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (req: CreateOutgoRequest) => api.post(`/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(`/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(`/api/budgets/${budgetId}/outgos/order`, req), + onSuccess: () => qc.invalidateQueries({ queryKey: ['budgets', budgetId, 'outgos'] }), + meta: { errorMessage: 'Failed to save order' }, + }); +} diff --git a/src/Budget.Client/src/api/queryClient.ts b/src/Budget.Client/src/api/queryClient.ts new file mode 100644 index 0000000..ab98f6c --- /dev/null +++ b/src/Budget.Client/src/api/queryClient.ts @@ -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'); + }, + }), +}); diff --git a/src/Budget.Client/src/api/shares.ts b/src/Budget.Client/src/api/shares.ts new file mode 100644 index 0000000..3d2a63f --- /dev/null +++ b/src/Budget.Client/src/api/shares.ts @@ -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(`/api/budgets/${budgetId}/shares`), + }); +} + +export function useAddShare(budgetId: string) { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (req: AddShareRequest) => api.post(`/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(`/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' }, + }); +} diff --git a/src/Budget.Client/src/api/summary.ts b/src/Budget.Client/src/api/summary.ts new file mode 100644 index 0000000..0d81bf4 --- /dev/null +++ b/src/Budget.Client/src/api/summary.ts @@ -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(`/api/budgets/${budgetId}/summary`), + }); +} + +export function useUpdateTaxRate(budgetId: string) { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (effectiveTaxRate: number) => + api.put(`/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' }, + }); +} diff --git a/src/Budget.Client/src/components/QueryToastBridge.tsx b/src/Budget.Client/src/components/QueryToastBridge.tsx new file mode 100644 index 0000000..9db4652 --- /dev/null +++ b/src/Budget.Client/src/components/QueryToastBridge.tsx @@ -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; +} diff --git a/src/Budget.Client/src/pages/BudgetsPage.tsx b/src/Budget.Client/src/pages/BudgetsPage.tsx index 2e89cf9..9522f2c 100644 --- a/src/Budget.Client/src/pages/BudgetsPage.tsx +++ b/src/Budget.Client/src/pages/BudgetsPage.tsx @@ -1,21 +1,16 @@ -import { useEffect, useState } from 'react'; +import { useState } from 'react'; import { useNavigate } from 'react-router-dom'; -import type { BudgetDto } from '../types'; -import { api } from '../api/client'; +import { useBudgets, useCreateBudget } from '../api/budgets'; export function BudgetsPage() { - const [budgets, setBudgets] = useState([]); const [newName, setNewName] = useState(''); const navigate = useNavigate(); - - useEffect(() => { - api.get('/api/budgets').then(setBudgets); - }, []); + const { data: budgets = [] } = useBudgets(); + const createBudget = useCreateBudget(); const handleCreate = async () => { if (!newName.trim()) return; - const created = await api.post('/api/budgets', { name: newName.trim() }); - setBudgets(prev => [...prev, created]); + const created = await createBudget.mutateAsync({ name: newName.trim() }); setNewName(''); navigate(`/budgets/${created.id}/income`); }; diff --git a/src/Budget.Client/src/pages/IncomePage.tsx b/src/Budget.Client/src/pages/IncomePage.tsx index 4db679b..499b961 100644 --- a/src/Budget.Client/src/pages/IncomePage.tsx +++ b/src/Budget.Client/src/pages/IncomePage.tsx @@ -1,6 +1,5 @@ -import { useEffect, useState } from 'react'; +import { useState, useEffect } from 'react'; import { useParams } from 'react-router-dom'; -import { useToast } from '../components/Toast'; import { LoadingSkeleton } from '../components/LoadingSkeleton'; import { DndContext, @@ -17,11 +16,11 @@ import { arrayMove, } from '@dnd-kit/sortable'; import { CSS } from '@dnd-kit/utilities'; -import type { IncomeDto, Frequency } from '../types'; -import { api } from '../api/client'; +import type { IncomeDto, Frequency } from '../types/index'; import { FrequencySelect } from '../components/FrequencySelect'; import { MoneyDisplay } from '../components/MoneyDisplay'; import { BudgetNav } from '../components/BudgetNav'; +import { useIncomes, useCreateIncome, useUpdateIncome, useDeleteIncome, useReorderIncomes } from '../api/incomes'; interface EditState { name: string; @@ -88,61 +87,41 @@ function SortableRow({ export function IncomePage() { const { id: budgetId } = useParams<{ id: string }>(); - const [incomes, setIncomes] = useState([]); - const [loading, setLoading] = useState(true); - const { showError } = useToast(); const sensors = useSensors(useSensor(PointerSensor)); - useEffect(() => { - if (!budgetId) return; - setLoading(true); - api.get(`/api/budgets/${budgetId}/incomes`) - .then(setIncomes) - .catch(e => showError(String(e))) - .finally(() => setLoading(false)); - }, [budgetId]); + const { data: incomes = [], isLoading } = useIncomes(budgetId!); + const [displayItems, setDisplayItems] = useState([]); + useEffect(() => { setDisplayItems(incomes); }, [incomes]); - const handleSave = async (id: string, edit: EditState) => { - try { - const updated = await api.put(`/api/budgets/${budgetId}/incomes/${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 updateIncome = useUpdateIncome(budgetId!); + const deleteIncome = useDeleteIncome(budgetId!); + const createIncome = useCreateIncome(budgetId!); + const reorderIncomes = useReorderIncomes(budgetId!); + + const handleSave = (id: string, edit: EditState) => { + updateIncome.mutate({ id, name: edit.name, frequency: edit.frequency, amount: parseFloat(edit.amount) }); }; - const handleDelete = async (id: string) => { + const handleDelete = (id: string) => { if (!confirm('Delete this income row?')) return; - try { - await api.delete(`/api/budgets/${budgetId}/incomes/${id}`); - setIncomes(prev => prev.filter(i => i.id !== id)); - } catch (e) { showError(String(e)); } + deleteIncome.mutate(id); }; - const handleAdd = async () => { - const created = await api.post(`/api/budgets/${budgetId}/incomes`, { - name: 'New Income', - frequency: 'Monthly', - amount: 0, - }); - setIncomes(prev => [...prev, created]); + const handleAdd = () => { + createIncome.mutate({ name: 'New Income', frequency: 'Monthly', amount: 0 }); }; - const handleDragEnd = async (event: DragEndEvent) => { + const handleDragEnd = (event: DragEndEvent) => { const { active, over } = event; if (!over || active.id === over.id) return; - const oldIdx = incomes.findIndex(i => i.id === active.id); - const newIdx = incomes.findIndex(i => i.id === over.id); - const reordered = arrayMove(incomes, oldIdx, newIdx); - setIncomes(reordered); - await api.put(`/api/budgets/${budgetId}/incomes/order`, { - orderedIds: reordered.map(i => i.id), - }); + const oldIdx = displayItems.findIndex(i => i.id === active.id); + const newIdx = displayItems.findIndex(i => i.id === over.id); + const reordered = arrayMove(displayItems, oldIdx, newIdx); + setDisplayItems(reordered); + reorderIncomes.mutate({ orderedIds: reordered.map(i => i.id) }); }; - if (loading) return <>; + if (isLoading) return <>; return (
@@ -161,9 +140,9 @@ export function IncomePage() { - i.id)} strategy={verticalListSortingStrategy}> + i.id)} strategy={verticalListSortingStrategy}> - {incomes.map(income => ( + {displayItems.map(income => ( (); - const [outgos, setOutgos] = useState([]); - const [categories, setCategories] = useState([]); - const [paymentSources, setPaymentSources] = useState([]); - const [loading, setLoading] = useState(true); - const { showError } = useToast(); const sensors = useSensors(useSensor(PointerSensor)); - useEffect(() => { - if (!budgetId) return; - setLoading(true); - Promise.all([ - api.get(`/api/budgets/${budgetId}/outgos`), - api.get(`/api/budgets/${budgetId}/outgos/categories`), - api.get(`/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 { data: outgos = [], isLoading } = useOutgos(budgetId!); + const { data: categories = [] } = useCategories(budgetId!); + const { data: paymentSources = [] } = usePaymentSources(budgetId!); + const [displayItems, setDisplayItems] = useState([]); + useEffect(() => { setDisplayItems(outgos); }, [outgos]); - const refreshSuggestions = () => { - if (!budgetId) return; - api.get(`/api/budgets/${budgetId}/outgos/categories`).then(setCategories); - api.get(`/api/budgets/${budgetId}/outgos/payment-sources`).then(setPaymentSources); - }; + const updateOutgo = useUpdateOutgo(budgetId!); + const deleteOutgo = useDeleteOutgo(budgetId!); + const createOutgo = useCreateOutgo(budgetId!); + const reorderOutgos = useReorderOutgos(budgetId!); - const handleSave = async (id: string, edit: EditState) => { - try { - const updated = await api.put(`/api/budgets/${budgetId}/outgos/${id}`, { - name: edit.name, - category: edit.category || null, - type: edit.type, - frequency: edit.frequency, - amount: parseFloat(edit.amount), - paymentSource: edit.paymentSource || 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(`/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), + const handleSave = (id: string, edit: EditState) => { + updateOutgo.mutate({ + id, + name: edit.name, + category: edit.category || null, + type: edit.type, + frequency: edit.frequency, + amount: parseFloat(edit.amount), + paymentSource: edit.paymentSource || null, + notes: edit.notes || null, }); }; - if (loading) return <>; + 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 <>; return (
@@ -230,9 +209,9 @@ export function OutgoPage() { - o.id)} strategy={verticalListSortingStrategy}> + o.id)} strategy={verticalListSortingStrategy}> - {outgos.map(outgo => ( + {displayItems.map(outgo => ( (); - const [budget, setBudget] = useState(null); - const [shares, setShares] = useState([]); + const { data: budget } = useBudget(budgetId!); + const { data: shares = [] } = useShares(budgetId!); + const [nameInput, setNameInput] = useState(''); const [taxInput, setTaxInput] = useState(''); const [shareEmail, setShareEmail] = useState(''); const [sharePermission, setSharePermission] = useState('View'); - const [saving, setSaving] = useState(false); useEffect(() => { - if (!budgetId) return; - api.get(`/api/budgets/${budgetId}`).then(b => { - setBudget(b); - setNameInput(b.name); - setTaxInput(String(Math.round(b.effectiveTaxRate * 100))); - }); - api.get(`/api/budgets/${budgetId}/shares`).then(setShares); - }, [budgetId]); + if (budget) { + setNameInput(budget.name); + setTaxInput(String(Math.round(budget.effectiveTaxRate * 100))); + } + }, [budget]); - const saveName = async () => { - if (!budgetId || !nameInput.trim()) return; - setSaving(true); - const updated = await api.put(`/api/budgets/${budgetId}`, { name: nameInput.trim() }); - setBudget(updated); - setSaving(false); + const updateBudget = useUpdateBudget(budgetId!); + const updateTaxRate = useUpdateTaxRate(budgetId!); + const addShare = useAddShare(budgetId!); + const updateShare = useUpdateShare(budgetId!); + const revokeShare = useRevokeShare(budgetId!); + + const saveName = () => { + if (!nameInput.trim()) return; + updateBudget.mutate({ name: nameInput.trim() }); }; - const saveTaxRate = async () => { - if (!budgetId) return; - setSaving(true); - const rate = parseFloat(taxInput) / 100; - await api.put(`/api/budgets/${budgetId}/summary/tax-rate`, { effectiveTaxRate: rate }); - const updated = await api.get(`/api/budgets/${budgetId}`); - setBudget(updated); - setSaving(false); + const saveTaxRate = () => { + updateTaxRate.mutate(parseFloat(taxInput) / 100); }; - const addShare = async () => { - if (!budgetId || !shareEmail.trim()) return; - const share = await api.post(`/api/budgets/${budgetId}/shares`, { - email: shareEmail.trim(), - permission: sharePermission, - }); - setShares(prev => [...prev, share]); + const handleAddShare = () => { + if (!shareEmail.trim()) return; + addShare.mutate({ email: shareEmail.trim(), permission: sharePermission }); setShareEmail(''); }; - const updatePermission = async (shareId: string, permission: SharePermission) => { - if (!budgetId) return; - const updated = await api.put(`/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
Loading...
; return ( @@ -74,7 +54,7 @@ export function SettingsPage() {

Rename Budget

setNameInput(e.target.value)} /> - +
@@ -89,7 +69,7 @@ export function SettingsPage() { onChange={e => setTaxInput(e.target.value)} style={{ width: '60px' }} /> - +
@@ -111,14 +91,14 @@ export function SettingsPage() { {s.isPending ? Pending : 'Active'} - + ))} @@ -134,7 +114,7 @@ export function SettingsPage() { - +
diff --git a/src/Budget.Client/src/pages/SummaryPage.tsx b/src/Budget.Client/src/pages/SummaryPage.tsx index 8d78abd..fb8447a 100644 --- a/src/Budget.Client/src/pages/SummaryPage.tsx +++ b/src/Budget.Client/src/pages/SummaryPage.tsx @@ -1,34 +1,25 @@ -import { useEffect, useState } from 'react'; +import { useState, useEffect } from 'react'; import { useParams } from 'react-router-dom'; -import type { SummaryDto } from '../types'; -import { api } from '../api/client'; import { MoneyDisplay } from '../components/MoneyDisplay'; import { BudgetNav } from '../components/BudgetNav'; +import { useSummary, useUpdateTaxRate } from '../api/summary'; const fmt = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }); export function SummaryPage() { const { id: budgetId } = useParams<{ id: string }>(); - const [summary, setSummary] = useState(null); + const { data: summary } = useSummary(budgetId!); const [taxRateInput, setTaxRateInput] = useState(''); - const [savingTax, setSavingTax] = useState(false); + const updateTaxRate = useUpdateTaxRate(budgetId!); useEffect(() => { - if (!budgetId) return; - api.get(`/api/budgets/${budgetId}/summary`).then(s => { - setSummary(s); - setTaxRateInput(String(Math.round(s.preTaxIncome.effectiveTaxRate * 100))); - }); - }, [budgetId]); + if (summary) { + setTaxRateInput(String(Math.round(summary.preTaxIncome.effectiveTaxRate * 100))); + } + }, [summary]); - const saveTaxRate = async () => { - if (!budgetId || !summary) return; - setSavingTax(true); - const rate = parseFloat(taxRateInput) / 100; - await api.put(`/api/budgets/${budgetId}/summary/tax-rate`, { effectiveTaxRate: rate }); - const updated = await api.get(`/api/budgets/${budgetId}/summary`); - setSummary(updated); - setSavingTax(false); + const saveTaxRate = () => { + updateTaxRate.mutate(parseFloat(taxRateInput) / 100); }; if (!summary) return
Loading...
; @@ -96,8 +87,8 @@ export function SummaryPage() { onChange={e => setTaxRateInput(e.target.value)} style={{ width: '60px' }} /> -