From 38296bc22a8e085b82cd177c642b66bc26d948c8 Mon Sep 17 00:00:00 2001 From: Spencer Twaddle <7374698+stwaddle@users.noreply.github.com> Date: Sat, 25 Apr 2026 07:57:52 -0500 Subject: [PATCH] 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 --- .../Controllers/OutgosController.cs | 136 ++++++++++ src/Budget.Api/DTOs/OutgoDtos.cs | 38 +++ .../src/components/AutocompleteInput.tsx | 53 ++++ src/Budget.Client/src/pages/OutgoPage.tsx | 236 +++++++++++++++++- 4 files changed, 461 insertions(+), 2 deletions(-) create mode 100644 src/Budget.Api/Controllers/OutgosController.cs create mode 100644 src/Budget.Api/DTOs/OutgoDtos.cs create mode 100644 src/Budget.Client/src/components/AutocompleteInput.tsx diff --git a/src/Budget.Api/Controllers/OutgosController.cs b/src/Budget.Api/Controllers/OutgosController.cs new file mode 100644 index 0000000..133f977 --- /dev/null +++ b/src/Budget.Api/Controllers/OutgosController.cs @@ -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 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 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 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 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 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 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 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 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); + } +} diff --git a/src/Budget.Api/DTOs/OutgoDtos.cs b/src/Budget.Api/DTOs/OutgoDtos.cs new file mode 100644 index 0000000..73f1cfe --- /dev/null +++ b/src/Budget.Api/DTOs/OutgoDtos.cs @@ -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 OrderedIds); diff --git a/src/Budget.Client/src/components/AutocompleteInput.tsx b/src/Budget.Client/src/components/AutocompleteInput.tsx new file mode 100644 index 0000000..eef684c --- /dev/null +++ b/src/Budget.Client/src/components/AutocompleteInput.tsx @@ -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(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 ( +
+ { onChange(e.target.value); setOpen(true); }} + onFocus={() => setOpen(true)} + /> + {open && filtered.length > 0 && ( +
    + {filtered.map(s => ( +
  • { onChange(s); setOpen(false); }} + > + {s} +
  • + ))} +
+ )} +
+ ); +} diff --git a/src/Budget.Client/src/pages/OutgoPage.tsx b/src/Budget.Client/src/pages/OutgoPage.tsx index a8bbec8..03586ca 100644 --- a/src/Budget.Client/src/pages/OutgoPage.tsx +++ b/src/Budget.Client/src/pages/OutgoPage.tsx @@ -1,3 +1,235 @@ -export function OutgoPage() { - return
Outgo — 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 { 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(null); + + const set = (patch: Partial) => setEditing(p => p && ({ ...p, ...patch })); + const save = () => { if (editing) { onSave(outgo.id, editing); setEditing(null); } }; + const cancel = () => setEditing(null); + + if (editing) { + return ( + + ⠿ + set({ name: e.target.value })} /> + + set({ category: v })} + suggestions={categories} + placeholder="Category" + /> + + + + + set({ frequency: v })} /> + set({ amount: e.target.value })} /> + + + + + set({ paymentSource: v })} + suggestions={paymentSources} + placeholder="Payment source" + /> + + set({ notes: e.target.value })} /> + + + + + + ); + } + + return ( + + ⠿ + setEditing(toEditState(outgo))}>{outgo.name} + setEditing(toEditState(outgo))}>{outgo.category} + setEditing(toEditState(outgo))}>{outgo.type} + setEditing(toEditState(outgo))}>{outgo.frequency} + setEditing(toEditState(outgo))}> + + + {outgo.monthlyPercent.toFixed(1)}% + setEditing(toEditState(outgo))}>{outgo.paymentSource} + setEditing(toEditState(outgo))}>{outgo.notes} + + + ); +} + +export function OutgoPage() { + const { id: budgetId } = useParams<{ id: string }>(); + const [outgos, setOutgos] = useState([]); + const [categories, setCategories] = useState([]); + const [paymentSources, setPaymentSources] = useState([]); + const sensors = useSensors(useSensor(PointerSensor)); + + useEffect(() => { + if (!budgetId) return; + api.get(`/api/budgets/${budgetId}/outgos`).then(setOutgos); + api.get(`/api/budgets/${budgetId}/outgos/categories`).then(setCategories); + api.get(`/api/budgets/${budgetId}/outgos/payment-sources`).then(setPaymentSources); + }, [budgetId]); + + const refreshSuggestions = () => { + if (!budgetId) return; + api.get(`/api/budgets/${budgetId}/outgos/categories`).then(setCategories); + api.get(`/api/budgets/${budgetId}/outgos/payment-sources`).then(setPaymentSources); + }; + + const handleSave = async (id: string, edit: EditState) => { + const updated = await api.put(`/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(`/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 ( +
+ +

Outgo

+
+ + + + + + + + + + + + + + + + + + + o.id)} strategy={verticalListSortingStrategy}> + + {outgos.map(outgo => ( + + ))} + + + +
NameCategoryTypeFrequencyAmountMonthlyAnnuallyMonthly%Payment SourceNotes
+
+ +
+ ); }