Phase 5: Outgo API and page

- Add OutgosController: list, create, update, delete, reorder, categories, payment-sources endpoints
- Outgo DTOs with computed Monthly, Annually, MonthlyPercent fields
- Add AutocompleteInput component with filtered suggestion dropdown
- Add Outgo page: full table with all columns, inline editing, add/delete, drag-to-reorder
- Autocomplete wired to categories and payment-sources API endpoints

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Spencer Twaddle
2026-04-25 07:57:52 -05:00
parent f429a747d8
commit 38296bc22a
4 changed files with 461 additions and 2 deletions
@@ -0,0 +1,136 @@
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}/outgos")]
[Authorize]
public class OutgosController(AppDbContext db, BudgetAuthorizationService authz) : ControllerBase
{
private string UserId => User.FindFirst("sub")!.Value;
private static OutgoDto ToDto(Outgo o, decimal totalMonthlyIncome) => new(
o.Id, o.BudgetId, o.Name, o.Category, o.Type, o.Frequency, o.Amount,
o.PaymentSource, o.Notes,
FrequencyCalculator.ToMonthly(o.Amount, o.Frequency),
FrequencyCalculator.ToAnnually(o.Amount, o.Frequency),
totalMonthlyIncome > 0
? Math.Round(FrequencyCalculator.ToMonthly(o.Amount, o.Frequency) / totalMonthlyIncome * 100, 2)
: 0m,
o.SortOrder);
private async Task<decimal> GetMonthlyIncomeAsync(Guid budgetId)
{
var incomes = await db.Incomes.Where(i => i.BudgetId == budgetId).ToListAsync();
return incomes.Sum(i => FrequencyCalculator.ToMonthly(i.Amount, i.Frequency));
}
[HttpGet]
public async Task<IActionResult> List(Guid budgetId)
{
if (!await authz.CanReadAsync(budgetId, UserId)) return Forbid();
var outgos = await db.Outgos.Where(o => o.BudgetId == budgetId).OrderBy(o => o.SortOrder).ToListAsync();
var monthlyIncome = await GetMonthlyIncomeAsync(budgetId);
return Ok(outgos.Select(o => ToDto(o, monthlyIncome)));
}
[HttpPost]
public async Task<IActionResult> Create(Guid budgetId, [FromBody] CreateOutgoRequest req)
{
if (!await authz.CanWriteAsync(budgetId, UserId)) return Forbid();
var maxOrder = await db.Outgos.Where(o => o.BudgetId == budgetId).MaxAsync(o => (int?)o.SortOrder) ?? -1;
var outgo = new Outgo
{
Id = Guid.NewGuid(),
BudgetId = budgetId,
Name = req.Name,
Category = req.Category,
Type = req.Type,
Frequency = req.Frequency,
Amount = req.Amount,
PaymentSource = req.PaymentSource,
Notes = req.Notes,
SortOrder = maxOrder + 1,
};
db.Outgos.Add(outgo);
await db.SaveChangesAsync();
var monthlyIncome = await GetMonthlyIncomeAsync(budgetId);
return Ok(ToDto(outgo, monthlyIncome));
}
[HttpPut("{outgoId:guid}")]
public async Task<IActionResult> Update(Guid budgetId, Guid outgoId, [FromBody] UpdateOutgoRequest req)
{
if (!await authz.CanWriteAsync(budgetId, UserId)) return Forbid();
var outgo = await db.Outgos.FirstOrDefaultAsync(o => o.Id == outgoId && o.BudgetId == budgetId);
if (outgo is null) return NotFound();
outgo.Name = req.Name;
outgo.Category = req.Category;
outgo.Type = req.Type;
outgo.Frequency = req.Frequency;
outgo.Amount = req.Amount;
outgo.PaymentSource = req.PaymentSource;
outgo.Notes = req.Notes;
await db.SaveChangesAsync();
var monthlyIncome = await GetMonthlyIncomeAsync(budgetId);
return Ok(ToDto(outgo, monthlyIncome));
}
[HttpDelete("{outgoId:guid}")]
public async Task<IActionResult> Delete(Guid budgetId, Guid outgoId)
{
if (!await authz.CanWriteAsync(budgetId, UserId)) return Forbid();
var outgo = await db.Outgos.FirstOrDefaultAsync(o => o.Id == outgoId && o.BudgetId == budgetId);
if (outgo is null) return NotFound();
db.Outgos.Remove(outgo);
await db.SaveChangesAsync();
return NoContent();
}
[HttpPut("order")]
public async Task<IActionResult> Reorder(Guid budgetId, [FromBody] ReorderOutgosRequest req)
{
if (!await authz.CanWriteAsync(budgetId, UserId)) return Forbid();
var outgos = await db.Outgos.Where(o => o.BudgetId == budgetId).ToListAsync();
var lookup = outgos.ToDictionary(o => o.Id);
for (int idx = 0; idx < req.OrderedIds.Count; idx++)
{
if (lookup.TryGetValue(req.OrderedIds[idx], out var outgo))
outgo.SortOrder = idx;
}
await db.SaveChangesAsync();
return NoContent();
}
[HttpGet("categories")]
public async Task<IActionResult> Categories(Guid budgetId)
{
if (!await authz.CanReadAsync(budgetId, UserId)) return Forbid();
var cats = await db.Outgos
.Where(o => o.BudgetId == budgetId && o.Category != null)
.Select(o => o.Category!)
.Distinct()
.OrderBy(c => c)
.ToListAsync();
return Ok(cats);
}
[HttpGet("payment-sources")]
public async Task<IActionResult> PaymentSources(Guid budgetId)
{
if (!await authz.CanReadAsync(budgetId, UserId)) return Forbid();
var sources = await db.Outgos
.Where(o => o.BudgetId == budgetId && o.PaymentSource != null)
.Select(o => o.PaymentSource!)
.Distinct()
.OrderBy(s => s)
.ToListAsync();
return Ok(sources);
}
}
+38
View File
@@ -0,0 +1,38 @@
using Budget.Api.Models;
namespace Budget.Api.DTOs;
public record OutgoDto(
Guid Id,
Guid BudgetId,
string Name,
string? Category,
OutgoType Type,
Frequency Frequency,
decimal Amount,
string? PaymentSource,
string? Notes,
decimal Monthly,
decimal Annually,
decimal MonthlyPercent,
int SortOrder);
public record CreateOutgoRequest(
string Name,
string? Category,
OutgoType Type,
Frequency Frequency,
decimal Amount,
string? PaymentSource,
string? Notes);
public record UpdateOutgoRequest(
string Name,
string? Category,
OutgoType Type,
Frequency Frequency,
decimal Amount,
string? PaymentSource,
string? Notes);
public record ReorderOutgosRequest(List<Guid> OrderedIds);
@@ -0,0 +1,53 @@
import { useState, useRef, useEffect } from 'react';
interface Props {
value: string;
onChange: (v: string) => void;
suggestions: string[];
placeholder?: string;
disabled?: boolean;
}
export function AutocompleteInput({ value, onChange, suggestions, placeholder, disabled }: Props) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
const filtered = suggestions.filter(s => s.toLowerCase().includes(value.toLowerCase()) && s !== value);
useEffect(() => {
const handler = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, []);
return (
<div ref={ref} style={{ position: 'relative', display: 'inline-block' }}>
<input
value={value}
placeholder={placeholder}
disabled={disabled}
onChange={e => { onChange(e.target.value); setOpen(true); }}
onFocus={() => setOpen(true)}
/>
{open && filtered.length > 0 && (
<ul style={{
position: 'absolute', top: '100%', left: 0, zIndex: 10,
background: 'white', border: '1px solid #ccc', listStyle: 'none',
margin: 0, padding: 0, minWidth: '100%',
}}>
{filtered.map(s => (
<li
key={s}
style={{ padding: '4px 8px', cursor: 'pointer' }}
onMouseDown={() => { onChange(s); setOpen(false); }}
>
{s}
</li>
))}
</ul>
)}
</div>
);
}
+234 -2
View File
@@ -1,3 +1,235 @@
export function OutgoPage() {
return <div>Outgo 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 { OutgoDto, Frequency, OutgoType } from '../types';
import { api } from '../api/client';
import { FrequencySelect } from '../components/FrequencySelect';
import { MoneyDisplay } from '../components/MoneyDisplay';
import { AutocompleteInput } from '../components/AutocompleteInput';
import { BudgetNav } from '../components/BudgetNav';
const OUTGO_TYPES: OutgoType[] = ['Need', 'Want', 'Save'];
interface EditState {
name: string;
category: string;
type: OutgoType;
frequency: Frequency;
amount: string;
paymentSource: string;
notes: string;
}
function toEditState(o: OutgoDto): EditState {
return {
name: o.name,
category: o.category ?? '',
type: o.type,
frequency: o.frequency,
amount: String(o.amount),
paymentSource: o.paymentSource ?? '',
notes: o.notes ?? '',
};
}
function SortableRow({
outgo,
categories,
paymentSources,
onSave,
onDelete,
}: {
outgo: OutgoDto;
categories: string[];
paymentSources: string[];
onSave: (id: string, edit: EditState) => void;
onDelete: (id: string) => void;
}) {
const { attributes, listeners, setNodeRef, transform, transition } = useSortable({ id: outgo.id });
const style = { transform: CSS.Transform.toString(transform), transition };
const [editing, setEditing] = useState<EditState | null>(null);
const set = (patch: Partial<EditState>) => setEditing(p => p && ({ ...p, ...patch }));
const save = () => { if (editing) { onSave(outgo.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 => set({ name: e.target.value })} /></td>
<td>
<AutocompleteInput
value={editing.category}
onChange={v => set({ category: v })}
suggestions={categories}
placeholder="Category"
/>
</td>
<td>
<select value={editing.type} onChange={e => set({ type: e.target.value as OutgoType })}>
{OUTGO_TYPES.map(t => <option key={t} value={t}>{t}</option>)}
</select>
</td>
<td><FrequencySelect value={editing.frequency} onChange={v => set({ frequency: v })} /></td>
<td><input type="number" value={editing.amount} onChange={e => set({ amount: e.target.value })} /></td>
<td></td>
<td></td>
<td></td>
<td>
<AutocompleteInput
value={editing.paymentSource}
onChange={v => set({ paymentSource: v })}
suggestions={paymentSources}
placeholder="Payment source"
/>
</td>
<td><input value={editing.notes} onChange={e => set({ notes: e.target.value })} /></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={() => setEditing(toEditState(outgo))}>{outgo.name}</td>
<td onClick={() => setEditing(toEditState(outgo))}>{outgo.category}</td>
<td onClick={() => setEditing(toEditState(outgo))}>{outgo.type}</td>
<td onClick={() => setEditing(toEditState(outgo))}>{outgo.frequency}</td>
<td onClick={() => setEditing(toEditState(outgo))}><MoneyDisplay value={outgo.amount} /></td>
<td><MoneyDisplay value={outgo.monthly} /></td>
<td><MoneyDisplay value={outgo.annually} /></td>
<td>{outgo.monthlyPercent.toFixed(1)}%</td>
<td onClick={() => setEditing(toEditState(outgo))}>{outgo.paymentSource}</td>
<td onClick={() => setEditing(toEditState(outgo))}>{outgo.notes}</td>
<td><button onClick={() => onDelete(outgo.id)}>Delete</button></td>
</tr>
);
}
export function OutgoPage() {
const { id: budgetId } = useParams<{ id: string }>();
const [outgos, setOutgos] = useState<OutgoDto[]>([]);
const [categories, setCategories] = useState<string[]>([]);
const [paymentSources, setPaymentSources] = useState<string[]>([]);
const sensors = useSensors(useSensor(PointerSensor));
useEffect(() => {
if (!budgetId) return;
api.get<OutgoDto[]>(`/api/budgets/${budgetId}/outgos`).then(setOutgos);
api.get<string[]>(`/api/budgets/${budgetId}/outgos/categories`).then(setCategories);
api.get<string[]>(`/api/budgets/${budgetId}/outgos/payment-sources`).then(setPaymentSources);
}, [budgetId]);
const refreshSuggestions = () => {
if (!budgetId) return;
api.get<string[]>(`/api/budgets/${budgetId}/outgos/categories`).then(setCategories);
api.get<string[]>(`/api/budgets/${budgetId}/outgos/payment-sources`).then(setPaymentSources);
};
const handleSave = async (id: string, edit: EditState) => {
const updated = await api.put<OutgoDto>(`/api/budgets/${budgetId}/outgos/${id}`, {
name: edit.name,
category: edit.category || null,
type: edit.type,
frequency: edit.frequency,
amount: parseFloat(edit.amount),
paymentSource: edit.paymentSource || null,
notes: edit.notes || null,
});
setOutgos(prev => prev.map(o => o.id === id ? updated : o));
refreshSuggestions();
};
const handleDelete = async (id: string) => {
await api.delete(`/api/budgets/${budgetId}/outgos/${id}`);
setOutgos(prev => prev.filter(o => o.id !== id));
};
const handleAdd = async () => {
const created = await api.post<OutgoDto>(`/api/budgets/${budgetId}/outgos`, {
name: 'New Outgo',
category: null,
type: 'Need',
frequency: 'Monthly',
amount: 0,
paymentSource: null,
notes: null,
});
setOutgos(prev => [...prev, created]);
};
const handleDragEnd = async (event: DragEndEvent) => {
const { active, over } = event;
if (!over || active.id === over.id) return;
const oldIdx = outgos.findIndex(o => o.id === active.id);
const newIdx = outgos.findIndex(o => o.id === over.id);
const reordered = arrayMove(outgos, oldIdx, newIdx);
setOutgos(reordered);
await api.put(`/api/budgets/${budgetId}/outgos/order`, {
orderedIds: reordered.map(o => o.id),
});
};
return (
<div>
<BudgetNav />
<h1>Outgo</h1>
<div style={{ overflowX: 'auto' }}>
<table>
<thead>
<tr>
<th></th>
<th>Name</th>
<th>Category</th>
<th>Type</th>
<th>Frequency</th>
<th>Amount</th>
<th>Monthly</th>
<th>Annually</th>
<th>Monthly%</th>
<th>Payment Source</th>
<th>Notes</th>
<th></th>
</tr>
</thead>
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext items={outgos.map(o => o.id)} strategy={verticalListSortingStrategy}>
<tbody>
{outgos.map(outgo => (
<SortableRow
key={outgo.id}
outgo={outgo}
categories={categories}
paymentSources={paymentSources}
onSave={handleSave}
onDelete={handleDelete}
/>
))}
</tbody>
</SortableContext>
</DndContext>
</table>
</div>
<button onClick={handleAdd}>+ Add Row</button>
</div>
);
}