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:
Generated
+27
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,7 +24,9 @@ const onSigninCallback = () => {
|
||||
function App() {
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ToastProvider>
|
||||
<QueryToastBridge />
|
||||
<AuthProvider {...authConfig} onSigninCallback={onSigninCallback}>
|
||||
<TokenSync />
|
||||
<BrowserRouter>
|
||||
@@ -37,6 +42,7 @@ function App() {
|
||||
</BrowserRouter>
|
||||
</AuthProvider>
|
||||
</ToastProvider>
|
||||
</QueryClientProvider>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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' },
|
||||
});
|
||||
}
|
||||
@@ -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' },
|
||||
});
|
||||
}
|
||||
@@ -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' },
|
||||
});
|
||||
}
|
||||
@@ -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');
|
||||
},
|
||||
}),
|
||||
});
|
||||
@@ -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' },
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<BudgetDto[]>([]);
|
||||
const [newName, setNewName] = useState('');
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
api.get<BudgetDto[]>('/api/budgets').then(setBudgets);
|
||||
}, []);
|
||||
const { data: budgets = [] } = useBudgets();
|
||||
const createBudget = useCreateBudget();
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!newName.trim()) return;
|
||||
const created = await api.post<BudgetDto>('/api/budgets', { name: newName.trim() });
|
||||
setBudgets(prev => [...prev, created]);
|
||||
const created = await createBudget.mutateAsync({ name: newName.trim() });
|
||||
setNewName('');
|
||||
navigate(`/budgets/${created.id}/income`);
|
||||
};
|
||||
|
||||
@@ -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<IncomeDto[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const { showError } = useToast();
|
||||
const sensors = useSensors(useSensor(PointerSensor));
|
||||
|
||||
useEffect(() => {
|
||||
if (!budgetId) return;
|
||||
setLoading(true);
|
||||
api.get<IncomeDto[]>(`/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<IncomeDto[]>([]);
|
||||
useEffect(() => { setDisplayItems(incomes); }, [incomes]);
|
||||
|
||||
const handleSave = async (id: string, edit: EditState) => {
|
||||
try {
|
||||
const updated = await api.put<IncomeDto>(`/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<IncomeDto>(`/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 <><BudgetNav /><LoadingSkeleton rows={5} cols={6} /></>;
|
||||
if (isLoading) return <><BudgetNav /><LoadingSkeleton rows={5} cols={6} /></>;
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -161,9 +140,9 @@ export function IncomePage() {
|
||||
</tr>
|
||||
</thead>
|
||||
<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>
|
||||
{incomes.map(income => (
|
||||
{displayItems.map(income => (
|
||||
<SortableRow
|
||||
key={income.id}
|
||||
income={income}
|
||||
|
||||
@@ -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,12 +16,15 @@ import {
|
||||
arrayMove,
|
||||
} from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import type { OutgoDto, Frequency, OutgoType } from '../types';
|
||||
import { api } from '../api/client';
|
||||
import type { OutgoDto, Frequency, OutgoType } from '../types/index';
|
||||
import { FrequencySelect } from '../components/FrequencySelect';
|
||||
import { MoneyDisplay } from '../components/MoneyDisplay';
|
||||
import { AutocompleteInput } from '../components/AutocompleteInput';
|
||||
import { BudgetNav } from '../components/BudgetNav';
|
||||
import {
|
||||
useOutgos, useCategories, usePaymentSources,
|
||||
useCreateOutgo, useUpdateOutgo, useDeleteOutgo, useReorderOutgos,
|
||||
} from '../api/outgos';
|
||||
|
||||
const OUTGO_TYPES: OutgoType[] = ['Need', 'Want', 'Save'];
|
||||
|
||||
@@ -129,34 +131,22 @@ function SortableRow({
|
||||
|
||||
export function OutgoPage() {
|
||||
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));
|
||||
|
||||
useEffect(() => {
|
||||
if (!budgetId) return;
|
||||
setLoading(true);
|
||||
Promise.all([
|
||||
api.get<OutgoDto[]>(`/api/budgets/${budgetId}/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 { data: outgos = [], isLoading } = useOutgos(budgetId!);
|
||||
const { data: categories = [] } = useCategories(budgetId!);
|
||||
const { data: paymentSources = [] } = usePaymentSources(budgetId!);
|
||||
const [displayItems, setDisplayItems] = useState<OutgoDto[]>([]);
|
||||
useEffect(() => { setDisplayItems(outgos); }, [outgos]);
|
||||
|
||||
const refreshSuggestions = () => {
|
||||
if (!budgetId) return;
|
||||
api.get<string[]>(`/api/budgets/${budgetId}/outgos/categories`).then(setCategories);
|
||||
api.get<string[]>(`/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<OutgoDto>(`/api/budgets/${budgetId}/outgos/${id}`, {
|
||||
const handleSave = (id: string, edit: EditState) => {
|
||||
updateOutgo.mutate({
|
||||
id,
|
||||
name: edit.name,
|
||||
category: edit.category || null,
|
||||
type: edit.type,
|
||||
@@ -165,22 +155,15 @@ export function OutgoPage() {
|
||||
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) => {
|
||||
const handleDelete = (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)); }
|
||||
deleteOutgo.mutate(id);
|
||||
};
|
||||
|
||||
const handleAdd = async () => {
|
||||
try {
|
||||
const created = await api.post<OutgoDto>(`/api/budgets/${budgetId}/outgos`, {
|
||||
const handleAdd = () => {
|
||||
createOutgo.mutate({
|
||||
name: 'New Outgo',
|
||||
category: null,
|
||||
type: 'Need',
|
||||
@@ -189,23 +172,19 @@ export function OutgoPage() {
|
||||
paymentSource: null,
|
||||
notes: null,
|
||||
});
|
||||
setOutgos(prev => [...prev, created]);
|
||||
} catch (e) { showError(String(e)); }
|
||||
};
|
||||
|
||||
const handleDragEnd = async (event: DragEndEvent) => {
|
||||
const handleDragEnd = (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 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 (loading) return <><BudgetNav /><LoadingSkeleton rows={5} cols={9} /></>;
|
||||
if (isLoading) return <><BudgetNav /><LoadingSkeleton rows={5} cols={9} /></>;
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -230,9 +209,9 @@ export function OutgoPage() {
|
||||
</tr>
|
||||
</thead>
|
||||
<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>
|
||||
{outgos.map(outgo => (
|
||||
{displayItems.map(outgo => (
|
||||
<SortableRow
|
||||
key={outgo.id}
|
||||
outgo={outgo}
|
||||
|
||||
@@ -1,69 +1,49 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import type { BudgetDto, ShareDto, SharePermission } from '../types';
|
||||
import { api } from '../api/client';
|
||||
import type { SharePermission } from '../types/index';
|
||||
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() {
|
||||
const { id: budgetId } = useParams<{ id: string }>();
|
||||
const [budget, setBudget] = useState<BudgetDto | null>(null);
|
||||
const [shares, setShares] = useState<ShareDto[]>([]);
|
||||
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<SharePermission>('View');
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!budgetId) return;
|
||||
api.get<BudgetDto>(`/api/budgets/${budgetId}`).then(b => {
|
||||
setBudget(b);
|
||||
setNameInput(b.name);
|
||||
setTaxInput(String(Math.round(b.effectiveTaxRate * 100)));
|
||||
});
|
||||
api.get<ShareDto[]>(`/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<BudgetDto>(`/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<BudgetDto>(`/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<ShareDto>(`/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<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>;
|
||||
|
||||
return (
|
||||
@@ -74,7 +54,7 @@ export function SettingsPage() {
|
||||
<section style={{ marginBottom: '2rem' }}>
|
||||
<h2>Rename Budget</h2>
|
||||
<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 style={{ marginBottom: '2rem' }}>
|
||||
@@ -89,7 +69,7 @@ export function SettingsPage() {
|
||||
onChange={e => setTaxInput(e.target.value)}
|
||||
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>
|
||||
</section>
|
||||
|
||||
@@ -111,14 +91,14 @@ export function SettingsPage() {
|
||||
<td>
|
||||
<select
|
||||
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="Edit">Edit</option>
|
||||
</select>
|
||||
</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>
|
||||
))}
|
||||
</tbody>
|
||||
@@ -134,7 +114,7 @@ export function SettingsPage() {
|
||||
<option value="View">View</option>
|
||||
<option value="Edit">Edit</option>
|
||||
</select>
|
||||
<button onClick={addShare}>Add Share</button>
|
||||
<button onClick={handleAddShare}>Add Share</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -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<SummaryDto | null>(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<SummaryDto>(`/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<SummaryDto>(`/api/budgets/${budgetId}/summary`);
|
||||
setSummary(updated);
|
||||
setSavingTax(false);
|
||||
const saveTaxRate = () => {
|
||||
updateTaxRate.mutate(parseFloat(taxRateInput) / 100);
|
||||
};
|
||||
|
||||
if (!summary) return <div>Loading...</div>;
|
||||
@@ -96,8 +87,8 @@ export function SummaryPage() {
|
||||
onChange={e => setTaxRateInput(e.target.value)}
|
||||
style={{ width: '60px' }}
|
||||
/>
|
||||
<button onClick={saveTaxRate} disabled={savingTax} style={{ marginLeft: '8px' }}>
|
||||
{savingTax ? 'Saving…' : 'Save'}
|
||||
<button onClick={saveTaxRate} disabled={updateTaxRate.isPending} style={{ marginLeft: '8px' }}>
|
||||
{updateTaxRate.isPending ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user