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:
@@ -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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<Guid> OrderedIds);
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
Generated
+57
-3
@@ -8,6 +8,9 @@
|
|||||||
"name": "budget-client",
|
"name": "budget-client",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"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",
|
"oidc-client-ts": "^3.5.0",
|
||||||
"react": "^19.2.5",
|
"react": "^19.2.5",
|
||||||
"react-dom": "^19.2.5",
|
"react-dom": "^19.2.5",
|
||||||
@@ -268,6 +271,59 @@
|
|||||||
"node": ">=6.9.0"
|
"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": {
|
"node_modules/@emnapi/core": {
|
||||||
"version": "1.10.0",
|
"version": "1.10.0",
|
||||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
|
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
|
||||||
@@ -2568,9 +2624,7 @@
|
|||||||
"version": "2.8.1",
|
"version": "2.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||||
"dev": true,
|
"license": "0BSD"
|
||||||
"license": "0BSD",
|
|
||||||
"optional": true
|
|
||||||
},
|
},
|
||||||
"node_modules/type-check": {
|
"node_modules/type-check": {
|
||||||
"version": "0.4.0",
|
"version": "0.4.0",
|
||||||
|
|||||||
@@ -10,6 +10,9 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"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",
|
"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,6 +1,8 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
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 { AuthGuard } from './auth/AuthGuard';
|
||||||
|
import { setTokenProvider } from './api/client';
|
||||||
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';
|
||||||
@@ -8,33 +10,25 @@ import { OutgoPage } from './pages/OutgoPage';
|
|||||||
import { SummaryPage } from './pages/SummaryPage';
|
import { SummaryPage } from './pages/SummaryPage';
|
||||||
import { SettingsPage } from './pages/SettingsPage';
|
import { SettingsPage } from './pages/SettingsPage';
|
||||||
|
|
||||||
|
function TokenWirer() {
|
||||||
|
const { getToken } = useAuth();
|
||||||
|
useEffect(() => { setTokenProvider(getToken); }, [getToken]);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
|
<TokenWirer />
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<Navigate to="/budgets" replace />} />
|
<Route path="/" element={<Navigate to="/budgets" replace />} />
|
||||||
<Route path="/callback" element={<CallbackPage />} />
|
<Route path="/callback" element={<CallbackPage />} />
|
||||||
<Route
|
<Route path="/budgets" element={<AuthGuard><BudgetsPage /></AuthGuard>} />
|
||||||
path="/budgets"
|
<Route path="/budgets/:id/income" element={<AuthGuard><IncomePage /></AuthGuard>} />
|
||||||
element={<AuthGuard><BudgetsPage /></AuthGuard>}
|
<Route path="/budgets/:id/outgo" element={<AuthGuard><OutgoPage /></AuthGuard>} />
|
||||||
/>
|
<Route path="/budgets/:id/summary" element={<AuthGuard><SummaryPage /></AuthGuard>} />
|
||||||
<Route
|
<Route path="/budgets/:id/settings" element={<AuthGuard><SettingsPage /></AuthGuard>} />
|
||||||
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>
|
</Routes>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</AuthProvider>
|
</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 { StrictMode } from 'react';
|
||||||
import { createRoot } from 'react-dom/client'
|
import { createRoot } from 'react-dom/client';
|
||||||
import './index.css'
|
import './index.css';
|
||||||
import App from './App.tsx'
|
import App from './App.tsx';
|
||||||
|
|
||||||
createRoot(document.getElementById('root')!).render(
|
createRoot(document.getElementById('root')!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<App />
|
<App />
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
)
|
);
|
||||||
|
|||||||
@@ -1,3 +1,165 @@
|
|||||||
export function IncomePage() {
|
import { useEffect, useState } from 'react';
|
||||||
return <div>Income — coming soon</div>;
|
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