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/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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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,7 +24,9 @@ const onSigninCallback = () => {
|
|||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
<ToastProvider>
|
<ToastProvider>
|
||||||
|
<QueryToastBridge />
|
||||||
<AuthProvider {...authConfig} onSigninCallback={onSigninCallback}>
|
<AuthProvider {...authConfig} onSigninCallback={onSigninCallback}>
|
||||||
<TokenSync />
|
<TokenSync />
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
@@ -37,6 +42,7 @@ function App() {
|
|||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
</ToastProvider>
|
</ToastProvider>
|
||||||
|
</QueryClientProvider>
|
||||||
</ErrorBoundary>
|
</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 { 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`);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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,34 +131,22 @@ 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,
|
||||||
@@ -165,22 +155,15 @@ export function OutgoPage() {
|
|||||||
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) => {
|
const handleDelete = (id: string) => {
|
||||||
if (!confirm('Delete this outgo row?')) return;
|
if (!confirm('Delete this outgo row?')) return;
|
||||||
try {
|
deleteOutgo.mutate(id);
|
||||||
await api.delete(`/api/budgets/${budgetId}/outgos/${id}`);
|
|
||||||
setOutgos(prev => prev.filter(o => o.id !== id));
|
|
||||||
} catch (e) { showError(String(e)); }
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAdd = async () => {
|
const handleAdd = () => {
|
||||||
try {
|
createOutgo.mutate({
|
||||||
const created = await api.post<OutgoDto>(`/api/budgets/${budgetId}/outgos`, {
|
|
||||||
name: 'New Outgo',
|
name: 'New Outgo',
|
||||||
category: null,
|
category: null,
|
||||||
type: 'Need',
|
type: 'Need',
|
||||||
@@ -189,23 +172,19 @@ export function OutgoPage() {
|
|||||||
paymentSource: null,
|
paymentSource: null,
|
||||||
notes: 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;
|
const { active, over } = event;
|
||||||
if (!over || active.id === over.id) return;
|
if (!over || active.id === over.id) return;
|
||||||
const oldIdx = outgos.findIndex(o => o.id === active.id);
|
const oldIdx = displayItems.findIndex(o => o.id === active.id);
|
||||||
const newIdx = outgos.findIndex(o => o.id === over.id);
|
const newIdx = displayItems.findIndex(o => o.id === over.id);
|
||||||
const reordered = arrayMove(outgos, oldIdx, newIdx);
|
const reordered = arrayMove(displayItems, oldIdx, newIdx);
|
||||||
setOutgos(reordered);
|
setDisplayItems(reordered);
|
||||||
await api.put(`/api/budgets/${budgetId}/outgos/order`, {
|
reorderOutgos.mutate({ orderedIds: reordered.map(o => o.id) });
|
||||||
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 (
|
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}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user