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:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user