From f429a747d8fba0d2bda6b8c8b6cc1d4a8a21e3e7 Mon Sep 17 00:00:00 2001 From: Spencer Twaddle <7374698+stwaddle@users.noreply.github.com> Date: Sat, 25 Apr 2026 07:56:42 -0500 Subject: [PATCH] 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 --- .../Controllers/IncomesController.cs | 92 ++++++++++ src/Budget.Api/DTOs/IncomeDtos.cs | 19 ++ .../Services/FrequencyCalculator.cs | 23 +++ src/Budget.Client/package-lock.json | 60 ++++++- src/Budget.Client/package.json | 3 + src/Budget.Client/src/App.tsx | 36 ++-- src/Budget.Client/src/api/client.ts | 30 ++++ .../src/components/BudgetNav.tsx | 14 ++ .../src/components/FrequencySelect.tsx | 31 ++++ .../src/components/MoneyDisplay.tsx | 10 ++ src/Budget.Client/src/main.tsx | 10 +- src/Budget.Client/src/pages/IncomePage.tsx | 166 +++++++++++++++++- src/Budget.Client/src/types/index.ts | 80 +++++++++ 13 files changed, 543 insertions(+), 31 deletions(-) create mode 100644 src/Budget.Api/Controllers/IncomesController.cs create mode 100644 src/Budget.Api/DTOs/IncomeDtos.cs create mode 100644 src/Budget.Api/Services/FrequencyCalculator.cs create mode 100644 src/Budget.Client/src/api/client.ts create mode 100644 src/Budget.Client/src/components/BudgetNav.tsx create mode 100644 src/Budget.Client/src/components/FrequencySelect.tsx create mode 100644 src/Budget.Client/src/components/MoneyDisplay.tsx create mode 100644 src/Budget.Client/src/types/index.ts diff --git a/src/Budget.Api/Controllers/IncomesController.cs b/src/Budget.Api/Controllers/IncomesController.cs new file mode 100644 index 0000000..066ae2c --- /dev/null +++ b/src/Budget.Api/Controllers/IncomesController.cs @@ -0,0 +1,92 @@ +using Budget.Api.Data; +using Budget.Api.DTOs; +using Budget.Api.Models; +using Budget.Api.Services; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace Budget.Api.Controllers; + +[ApiController] +[Route("api/budgets/{budgetId:guid}/incomes")] +[Authorize] +public class IncomesController(AppDbContext db, BudgetAuthorizationService authz) : ControllerBase +{ + private string UserId => User.FindFirst("sub")!.Value; + + private static IncomeDto ToDto(Income i) => new( + i.Id, i.BudgetId, i.Name, i.Frequency, i.Amount, + FrequencyCalculator.ToMonthly(i.Amount, i.Frequency), + FrequencyCalculator.ToAnnually(i.Amount, i.Frequency), + i.SortOrder); + + [HttpGet] + public async Task List(Guid budgetId) + { + if (!await authz.CanReadAsync(budgetId, UserId)) return Forbid(); + var incomes = await db.Incomes + .Where(i => i.BudgetId == budgetId) + .OrderBy(i => i.SortOrder) + .ToListAsync(); + return Ok(incomes.Select(ToDto)); + } + + [HttpPost] + public async Task Create(Guid budgetId, [FromBody] CreateIncomeRequest req) + { + if (!await authz.CanWriteAsync(budgetId, UserId)) return Forbid(); + var maxOrder = await db.Incomes.Where(i => i.BudgetId == budgetId).MaxAsync(i => (int?)i.SortOrder) ?? -1; + var income = new Income + { + Id = Guid.NewGuid(), + BudgetId = budgetId, + Name = req.Name, + Frequency = req.Frequency, + Amount = req.Amount, + SortOrder = maxOrder + 1, + }; + db.Incomes.Add(income); + await db.SaveChangesAsync(); + return Ok(ToDto(income)); + } + + [HttpPut("{incomeId:guid}")] + public async Task Update(Guid budgetId, Guid incomeId, [FromBody] UpdateIncomeRequest req) + { + if (!await authz.CanWriteAsync(budgetId, UserId)) return Forbid(); + var income = await db.Incomes.FirstOrDefaultAsync(i => i.Id == incomeId && i.BudgetId == budgetId); + if (income is null) return NotFound(); + income.Name = req.Name; + income.Frequency = req.Frequency; + income.Amount = req.Amount; + await db.SaveChangesAsync(); + return Ok(ToDto(income)); + } + + [HttpDelete("{incomeId:guid}")] + public async Task Delete(Guid budgetId, Guid incomeId) + { + if (!await authz.CanWriteAsync(budgetId, UserId)) return Forbid(); + var income = await db.Incomes.FirstOrDefaultAsync(i => i.Id == incomeId && i.BudgetId == budgetId); + if (income is null) return NotFound(); + db.Incomes.Remove(income); + await db.SaveChangesAsync(); + return NoContent(); + } + + [HttpPut("order")] + public async Task Reorder(Guid budgetId, [FromBody] ReorderIncomesRequest req) + { + if (!await authz.CanWriteAsync(budgetId, UserId)) return Forbid(); + var incomes = await db.Incomes.Where(i => i.BudgetId == budgetId).ToListAsync(); + var lookup = incomes.ToDictionary(i => i.Id); + for (int idx = 0; idx < req.OrderedIds.Count; idx++) + { + if (lookup.TryGetValue(req.OrderedIds[idx], out var income)) + income.SortOrder = idx; + } + await db.SaveChangesAsync(); + return NoContent(); + } +} diff --git a/src/Budget.Api/DTOs/IncomeDtos.cs b/src/Budget.Api/DTOs/IncomeDtos.cs new file mode 100644 index 0000000..e9eb3b8 --- /dev/null +++ b/src/Budget.Api/DTOs/IncomeDtos.cs @@ -0,0 +1,19 @@ +using Budget.Api.Models; + +namespace Budget.Api.DTOs; + +public record IncomeDto( + Guid Id, + Guid BudgetId, + string Name, + Frequency Frequency, + decimal Amount, + decimal Monthly, + decimal Annually, + int SortOrder); + +public record CreateIncomeRequest(string Name, Frequency Frequency, decimal Amount); + +public record UpdateIncomeRequest(string Name, Frequency Frequency, decimal Amount); + +public record ReorderIncomesRequest(List OrderedIds); diff --git a/src/Budget.Api/Services/FrequencyCalculator.cs b/src/Budget.Api/Services/FrequencyCalculator.cs new file mode 100644 index 0000000..24415ad --- /dev/null +++ b/src/Budget.Api/Services/FrequencyCalculator.cs @@ -0,0 +1,23 @@ +using Budget.Api.Models; + +namespace Budget.Api.Services; + +public static class FrequencyCalculator +{ + public static decimal ToMonthly(decimal amount, Frequency frequency) => frequency switch + { + Frequency.Biennial => amount / 24m, + Frequency.Annually => amount / 12m, + Frequency.Biannually => amount * 2m / 12m, + Frequency.Quarterly => amount * 4m / 12m, + Frequency.EveryTwoMonths => amount * 6m / 12m, + Frequency.Monthly => amount, + Frequency.SemiMonthly => amount * 2m, + Frequency.Biweekly => amount * 26m / 12m, + Frequency.Weekly => amount * 52m / 12m, + _ => throw new ArgumentOutOfRangeException(nameof(frequency)), + }; + + public static decimal ToAnnually(decimal amount, Frequency frequency) => + ToMonthly(amount, frequency) * 12m; +} diff --git a/src/Budget.Client/package-lock.json b/src/Budget.Client/package-lock.json index a595c83..1a6e5e9 100644 --- a/src/Budget.Client/package-lock.json +++ b/src/Budget.Client/package-lock.json @@ -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", diff --git a/src/Budget.Client/package.json b/src/Budget.Client/package.json index 4ae7988..e4bd440 100644 --- a/src/Budget.Client/package.json +++ b/src/Budget.Client/package.json @@ -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", diff --git a/src/Budget.Client/src/App.tsx b/src/Budget.Client/src/App.tsx index 0fb36e8..5a9b74f 100644 --- a/src/Budget.Client/src/App.tsx +++ b/src/Budget.Client/src/App.tsx @@ -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 ( + } /> } /> - } - /> - } - /> - } - /> - } - /> - } - /> + } /> + } /> + } /> + } /> + } /> diff --git a/src/Budget.Client/src/api/client.ts b/src/Budget.Client/src/api/client.ts new file mode 100644 index 0000000..450f635 --- /dev/null +++ b/src/Budget.Client/src/api/client.ts @@ -0,0 +1,30 @@ +let getToken: (() => string | null) | null = null; + +export function setTokenProvider(fn: () => string | null) { + getToken = fn; +} + +async function request(method: string, path: string, body?: unknown): Promise { + 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: (path: string) => request('GET', path), + post: (path: string, body: unknown) => request('POST', path, body), + put: (path: string, body: unknown) => request('PUT', path, body), + delete: (path: string) => request('DELETE', path), +}; diff --git a/src/Budget.Client/src/components/BudgetNav.tsx b/src/Budget.Client/src/components/BudgetNav.tsx new file mode 100644 index 0000000..e1b32af --- /dev/null +++ b/src/Budget.Client/src/components/BudgetNav.tsx @@ -0,0 +1,14 @@ +import { NavLink, useParams } from 'react-router-dom'; + +export function BudgetNav() { + const { id } = useParams<{ id: string }>(); + const base = `/budgets/${id}`; + return ( + + ); +} diff --git a/src/Budget.Client/src/components/FrequencySelect.tsx b/src/Budget.Client/src/components/FrequencySelect.tsx new file mode 100644 index 0000000..a97922c --- /dev/null +++ b/src/Budget.Client/src/components/FrequencySelect.tsx @@ -0,0 +1,31 @@ +import type { Frequency } from '../types'; + +const FREQUENCY_LABELS: Record = { + 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 ( + + ); +} diff --git a/src/Budget.Client/src/components/MoneyDisplay.tsx b/src/Budget.Client/src/components/MoneyDisplay.tsx new file mode 100644 index 0000000..2f5936a --- /dev/null +++ b/src/Budget.Client/src/components/MoneyDisplay.tsx @@ -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 {fmt.format(value)}; +} diff --git a/src/Budget.Client/src/main.tsx b/src/Budget.Client/src/main.tsx index bef5202..2239905 100644 --- a/src/Budget.Client/src/main.tsx +++ b/src/Budget.Client/src/main.tsx @@ -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( , -) +); diff --git a/src/Budget.Client/src/pages/IncomePage.tsx b/src/Budget.Client/src/pages/IncomePage.tsx index 0fe2d9d..ce397f4 100644 --- a/src/Budget.Client/src/pages/IncomePage.tsx +++ b/src/Budget.Client/src/pages/IncomePage.tsx @@ -1,3 +1,165 @@ -export function IncomePage() { - return
Income — coming soon
; +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(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 ( + + ⠿ + setEditing(p => p && ({ ...p, name: e.target.value }))} /> + + setEditing(p => p && ({ ...p, frequency: v }))} + /> + + setEditing(p => p && ({ ...p, amount: e.target.value }))} /> + + + + + + + + ); + } + + return ( + + ⠿ + {income.name} + {income.frequency} + + + + + + ); +} + +export function IncomePage() { + const { id: budgetId } = useParams<{ id: string }>(); + const [incomes, setIncomes] = useState([]); + const sensors = useSensors(useSensor(PointerSensor)); + + useEffect(() => { + if (budgetId) api.get(`/api/budgets/${budgetId}/incomes`).then(setIncomes); + }, [budgetId]); + + const handleSave = async (id: string, edit: EditState) => { + const updated = await api.put(`/api/budgets/${budgetId}/incomes/${id}`, { + name: edit.name, + frequency: edit.frequency, + amount: parseFloat(edit.amount), + }); + setIncomes(prev => prev.map(i => i.id === id ? updated : i)); + }; + + 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(`/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 ( +
+ +

Income

+ + + + + + + + + + + + + + i.id)} strategy={verticalListSortingStrategy}> + + {incomes.map(income => ( + + ))} + + + +
NameFrequencyAmountMonthlyAnnually
+ +
+ ); } diff --git a/src/Budget.Client/src/types/index.ts b/src/Budget.Client/src/types/index.ts new file mode 100644 index 0000000..09ebc4e --- /dev/null +++ b/src/Budget.Client/src/types/index.ts @@ -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; + }; +}