Phase 4: Income API and page
- Add FrequencyCalculator static utility (all 9 frequency multipliers) - Add IncomesController: list, create, update, delete, reorder - Add Income DTOs with computed Monthly/Annually fields - Add shared TypeScript types (IncomeDto, OutgoDto, BudgetDto, ShareDto, SummaryDto) - Add API client with Bearer token injection via setTokenProvider - Add FrequencySelect, MoneyDisplay, BudgetNav shared components - Add Income page: sortable table with inline editing, add/delete rows, drag-to-reorder via dnd-kit - Wire TokenWirer in App.tsx to keep API client in sync with auth state Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Generated
+57
-3
@@ -8,6 +8,9 @@
|
||||
"name": "budget-client",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"oidc-client-ts": "^3.5.0",
|
||||
"react": "^19.2.5",
|
||||
"react-dom": "^19.2.5",
|
||||
@@ -268,6 +271,59 @@
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/accessibility": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
|
||||
"integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/core": {
|
||||
"version": "6.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
|
||||
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@dnd-kit/accessibility": "^3.1.1",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/sortable": {
|
||||
"version": "10.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz",
|
||||
"integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@dnd-kit/core": "^6.3.0",
|
||||
"react": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/utilities": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
|
||||
"integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/core": {
|
||||
"version": "1.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
|
||||
@@ -2568,9 +2624,7 @@
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"dev": true,
|
||||
"license": "0BSD",
|
||||
"optional": true
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/type-check": {
|
||||
"version": "0.4.0",
|
||||
|
||||
@@ -10,6 +10,9 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"oidc-client-ts": "^3.5.0",
|
||||
"react": "^19.2.5",
|
||||
"react-dom": "^19.2.5",
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useEffect } from 'react';
|
||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { AuthProvider } from './auth/AuthContext';
|
||||
import { AuthProvider, useAuth } from './auth/AuthContext';
|
||||
import { AuthGuard } from './auth/AuthGuard';
|
||||
import { setTokenProvider } from './api/client';
|
||||
import { CallbackPage } from './pages/CallbackPage';
|
||||
import { BudgetsPage } from './pages/BudgetsPage';
|
||||
import { IncomePage } from './pages/IncomePage';
|
||||
@@ -8,33 +10,25 @@ import { OutgoPage } from './pages/OutgoPage';
|
||||
import { SummaryPage } from './pages/SummaryPage';
|
||||
import { SettingsPage } from './pages/SettingsPage';
|
||||
|
||||
function TokenWirer() {
|
||||
const { getToken } = useAuth();
|
||||
useEffect(() => { setTokenProvider(getToken); }, [getToken]);
|
||||
return null;
|
||||
}
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<AuthProvider>
|
||||
<TokenWirer />
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/" element={<Navigate to="/budgets" replace />} />
|
||||
<Route path="/callback" element={<CallbackPage />} />
|
||||
<Route
|
||||
path="/budgets"
|
||||
element={<AuthGuard><BudgetsPage /></AuthGuard>}
|
||||
/>
|
||||
<Route
|
||||
path="/budgets/:id/income"
|
||||
element={<AuthGuard><IncomePage /></AuthGuard>}
|
||||
/>
|
||||
<Route
|
||||
path="/budgets/:id/outgo"
|
||||
element={<AuthGuard><OutgoPage /></AuthGuard>}
|
||||
/>
|
||||
<Route
|
||||
path="/budgets/:id/summary"
|
||||
element={<AuthGuard><SummaryPage /></AuthGuard>}
|
||||
/>
|
||||
<Route
|
||||
path="/budgets/:id/settings"
|
||||
element={<AuthGuard><SettingsPage /></AuthGuard>}
|
||||
/>
|
||||
<Route path="/budgets" element={<AuthGuard><BudgetsPage /></AuthGuard>} />
|
||||
<Route path="/budgets/:id/income" element={<AuthGuard><IncomePage /></AuthGuard>} />
|
||||
<Route path="/budgets/:id/outgo" element={<AuthGuard><OutgoPage /></AuthGuard>} />
|
||||
<Route path="/budgets/:id/summary" element={<AuthGuard><SummaryPage /></AuthGuard>} />
|
||||
<Route path="/budgets/:id/settings" element={<AuthGuard><SettingsPage /></AuthGuard>} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
</AuthProvider>
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
let getToken: (() => string | null) | null = null;
|
||||
|
||||
export function setTokenProvider(fn: () => string | null) {
|
||||
getToken = fn;
|
||||
}
|
||||
|
||||
async function request<T>(method: string, path: string, body?: unknown): Promise<T> {
|
||||
const token = getToken?.();
|
||||
const res = await fetch(path, {
|
||||
method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
},
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => '');
|
||||
throw new Error(`${method} ${path} → ${res.status}: ${text}`);
|
||||
}
|
||||
if (res.status === 204) return undefined as T;
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export const api = {
|
||||
get: <T>(path: string) => request<T>('GET', path),
|
||||
post: <T>(path: string, body: unknown) => request<T>('POST', path, body),
|
||||
put: <T>(path: string, body: unknown) => request<T>('PUT', path, body),
|
||||
delete: (path: string) => request<void>('DELETE', path),
|
||||
};
|
||||
@@ -0,0 +1,14 @@
|
||||
import { NavLink, useParams } from 'react-router-dom';
|
||||
|
||||
export function BudgetNav() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const base = `/budgets/${id}`;
|
||||
return (
|
||||
<nav style={{ display: 'flex', gap: '1rem', marginBottom: '1rem' }}>
|
||||
<NavLink to={`${base}/income`}>Income</NavLink>
|
||||
<NavLink to={`${base}/outgo`}>Outgo</NavLink>
|
||||
<NavLink to={`${base}/summary`}>Summary</NavLink>
|
||||
<NavLink to={`${base}/settings`}>Settings</NavLink>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { Frequency } from '../types';
|
||||
|
||||
const FREQUENCY_LABELS: Record<Frequency, string> = {
|
||||
Biennial: 'Biennial',
|
||||
Annually: 'Annually',
|
||||
Biannually: 'Biannually',
|
||||
Quarterly: 'Quarterly',
|
||||
EveryTwoMonths: 'Every 2 Months',
|
||||
Monthly: 'Monthly',
|
||||
SemiMonthly: 'Semi-Monthly',
|
||||
Biweekly: 'Biweekly',
|
||||
Weekly: 'Weekly',
|
||||
};
|
||||
|
||||
const FREQUENCIES = Object.keys(FREQUENCY_LABELS) as Frequency[];
|
||||
|
||||
interface Props {
|
||||
value: Frequency;
|
||||
onChange: (v: Frequency) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function FrequencySelect({ value, onChange, disabled }: Props) {
|
||||
return (
|
||||
<select value={value} onChange={e => onChange(e.target.value as Frequency)} disabled={disabled}>
|
||||
{FREQUENCIES.map(f => (
|
||||
<option key={f} value={f}>{FREQUENCY_LABELS[f]}</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
interface Props {
|
||||
value: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const fmt = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' });
|
||||
|
||||
export function MoneyDisplay({ value, className }: Props) {
|
||||
return <span className={className}>{fmt.format(value)}</span>;
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import './index.css';
|
||||
import App from './App.tsx';
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
);
|
||||
|
||||
@@ -1,3 +1,165 @@
|
||||
export function IncomePage() {
|
||||
return <div>Income — coming soon</div>;
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import {
|
||||
DndContext,
|
||||
closestCenter,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
} from '@dnd-kit/core';
|
||||
import type { DragEndEvent } from '@dnd-kit/core';
|
||||
import {
|
||||
SortableContext,
|
||||
useSortable,
|
||||
verticalListSortingStrategy,
|
||||
arrayMove,
|
||||
} from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import type { IncomeDto, Frequency } from '../types';
|
||||
import { api } from '../api/client';
|
||||
import { FrequencySelect } from '../components/FrequencySelect';
|
||||
import { MoneyDisplay } from '../components/MoneyDisplay';
|
||||
import { BudgetNav } from '../components/BudgetNav';
|
||||
|
||||
interface EditState {
|
||||
name: string;
|
||||
frequency: Frequency;
|
||||
amount: string;
|
||||
}
|
||||
|
||||
function SortableRow({
|
||||
income,
|
||||
onSave,
|
||||
onDelete,
|
||||
}: {
|
||||
income: IncomeDto;
|
||||
onSave: (id: string, edit: EditState) => void;
|
||||
onDelete: (id: string) => void;
|
||||
}) {
|
||||
const { attributes, listeners, setNodeRef, transform, transition } = useSortable({ id: income.id });
|
||||
const style = { transform: CSS.Transform.toString(transform), transition };
|
||||
const [editing, setEditing] = useState<EditState | null>(null);
|
||||
|
||||
const startEdit = () =>
|
||||
setEditing({ name: income.name, frequency: income.frequency, amount: String(income.amount) });
|
||||
|
||||
const save = () => {
|
||||
if (editing) { onSave(income.id, editing); setEditing(null); }
|
||||
};
|
||||
|
||||
const cancel = () => setEditing(null);
|
||||
|
||||
if (editing) {
|
||||
return (
|
||||
<tr ref={setNodeRef} style={style}>
|
||||
<td>⠿</td>
|
||||
<td><input value={editing.name} onChange={e => setEditing(p => p && ({ ...p, name: e.target.value }))} /></td>
|
||||
<td>
|
||||
<FrequencySelect
|
||||
value={editing.frequency}
|
||||
onChange={v => setEditing(p => p && ({ ...p, frequency: v }))}
|
||||
/>
|
||||
</td>
|
||||
<td><input type="number" value={editing.amount} onChange={e => setEditing(p => p && ({ ...p, amount: e.target.value }))} /></td>
|
||||
<td></td>
|
||||
<td></td>
|
||||
<td>
|
||||
<button onClick={save}>Save</button>
|
||||
<button onClick={cancel}>Cancel</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<tr ref={setNodeRef} style={style}>
|
||||
<td {...attributes} {...listeners} style={{ cursor: 'grab' }}>⠿</td>
|
||||
<td onClick={startEdit}>{income.name}</td>
|
||||
<td onClick={startEdit}>{income.frequency}</td>
|
||||
<td onClick={startEdit}><MoneyDisplay value={income.amount} /></td>
|
||||
<td><MoneyDisplay value={income.monthly} /></td>
|
||||
<td><MoneyDisplay value={income.annually} /></td>
|
||||
<td><button onClick={() => onDelete(income.id)}>Delete</button></td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
export function IncomePage() {
|
||||
const { id: budgetId } = useParams<{ id: string }>();
|
||||
const [incomes, setIncomes] = useState<IncomeDto[]>([]);
|
||||
const sensors = useSensors(useSensor(PointerSensor));
|
||||
|
||||
useEffect(() => {
|
||||
if (budgetId) api.get<IncomeDto[]>(`/api/budgets/${budgetId}/incomes`).then(setIncomes);
|
||||
}, [budgetId]);
|
||||
|
||||
const handleSave = async (id: string, edit: EditState) => {
|
||||
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));
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
await api.delete(`/api/budgets/${budgetId}/incomes/${id}`);
|
||||
setIncomes(prev => prev.filter(i => i.id !== 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 handleDragEnd = async (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),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<BudgetNav />
|
||||
<h1>Income</h1>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th></th>
|
||||
<th>Name</th>
|
||||
<th>Frequency</th>
|
||||
<th>Amount</th>
|
||||
<th>Monthly</th>
|
||||
<th>Annually</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
||||
<SortableContext items={incomes.map(i => i.id)} strategy={verticalListSortingStrategy}>
|
||||
<tbody>
|
||||
{incomes.map(income => (
|
||||
<SortableRow
|
||||
key={income.id}
|
||||
income={income}
|
||||
onSave={handleSave}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
</table>
|
||||
<button onClick={handleAdd}>+ Add Row</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
export type Frequency =
|
||||
| 'Biennial'
|
||||
| 'Annually'
|
||||
| 'Biannually'
|
||||
| 'Quarterly'
|
||||
| 'EveryTwoMonths'
|
||||
| 'Monthly'
|
||||
| 'SemiMonthly'
|
||||
| 'Biweekly'
|
||||
| 'Weekly';
|
||||
|
||||
export type OutgoType = 'Need' | 'Want' | 'Save';
|
||||
|
||||
export type SharePermission = 'View' | 'Edit';
|
||||
|
||||
export interface IncomeDto {
|
||||
id: string;
|
||||
budgetId: string;
|
||||
name: string;
|
||||
frequency: Frequency;
|
||||
amount: number;
|
||||
monthly: number;
|
||||
annually: number;
|
||||
sortOrder: number;
|
||||
}
|
||||
|
||||
export interface OutgoDto {
|
||||
id: string;
|
||||
budgetId: string;
|
||||
name: string;
|
||||
category: string | null;
|
||||
type: OutgoType;
|
||||
frequency: Frequency;
|
||||
amount: number;
|
||||
paymentSource: string | null;
|
||||
notes: string | null;
|
||||
monthly: number;
|
||||
annually: number;
|
||||
monthlyPercent: number;
|
||||
sortOrder: number;
|
||||
}
|
||||
|
||||
export interface BudgetDto {
|
||||
id: string;
|
||||
name: string;
|
||||
ownerUserId: string;
|
||||
effectiveTaxRate: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface ShareDto {
|
||||
id: string;
|
||||
sharedWithUserId: string | null;
|
||||
sharedWithEmail: string;
|
||||
permission: SharePermission;
|
||||
isPending: boolean;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface SummaryBreakdownItem {
|
||||
type: string;
|
||||
targetPercent: number | null;
|
||||
total: number;
|
||||
annually: number;
|
||||
percent: number;
|
||||
maxAmount?: number;
|
||||
remaining?: number;
|
||||
}
|
||||
|
||||
export interface SummaryDto {
|
||||
monthlyIncome: number;
|
||||
annualIncome: number;
|
||||
breakdown: SummaryBreakdownItem[];
|
||||
preTaxIncome: {
|
||||
effectiveTaxRate: number;
|
||||
minimumAnnualGross: number;
|
||||
minimumMonthlyGross: number;
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user