Phase 6: Summary API and page
- Add SummaryController: computes monthly income, breakdown by type (Need/Want/Save/Unspent), and pre-tax income - Need/Want/Save get target% (50/30/20), maxAmount, and remaining; Unspent shows totals only - PUT /summary/tax-rate updates EffectiveTaxRate on the budget (no new migration needed) - Add SummaryDto, SummaryBreakdownItem, PreTaxIncomeDto DTOs - Add Summary page: income header cards, type breakdown table with ⓘ tooltip for target%, pre-tax section with editable tax rate field Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,81 @@
|
||||
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}/summary")]
|
||||
[Authorize]
|
||||
public class SummaryController(AppDbContext db, BudgetAuthorizationService authz) : ControllerBase
|
||||
{
|
||||
private string UserId => User.FindFirst("sub")!.Value;
|
||||
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> Get(Guid budgetId)
|
||||
{
|
||||
if (!await authz.CanReadAsync(budgetId, UserId)) return Forbid();
|
||||
|
||||
var budget = await db.Budgets.FindAsync(budgetId);
|
||||
if (budget is null) return NotFound();
|
||||
|
||||
var incomes = await db.Incomes.Where(i => i.BudgetId == budgetId).ToListAsync();
|
||||
var outgos = await db.Outgos.Where(o => o.BudgetId == budgetId).ToListAsync();
|
||||
|
||||
var monthlyIncome = incomes.Sum(i => FrequencyCalculator.ToMonthly(i.Amount, i.Frequency));
|
||||
var annualIncome = monthlyIncome * 12m;
|
||||
|
||||
var typeTargets = new Dictionary<OutgoType, int>
|
||||
{
|
||||
[OutgoType.Need] = 50,
|
||||
[OutgoType.Want] = 30,
|
||||
[OutgoType.Save] = 20,
|
||||
};
|
||||
|
||||
var breakdown = new List<SummaryBreakdownItem>();
|
||||
decimal totalMonthlyOutgo = 0m;
|
||||
|
||||
foreach (var (type, target) in typeTargets)
|
||||
{
|
||||
var typeOutgos = outgos.Where(o => o.Type == type).ToList();
|
||||
var monthlyTotal = typeOutgos.Sum(o => FrequencyCalculator.ToMonthly(o.Amount, o.Frequency));
|
||||
var annualTotal = monthlyTotal * 12m;
|
||||
totalMonthlyOutgo += monthlyTotal;
|
||||
var percent = monthlyIncome > 0 ? Math.Round(monthlyTotal / monthlyIncome * 100, 2) : 0m;
|
||||
var maxAmount = monthlyIncome * target / 100m;
|
||||
var remaining = maxAmount - monthlyTotal;
|
||||
breakdown.Add(new SummaryBreakdownItem(type.ToString(), target, monthlyTotal, annualTotal, percent, maxAmount, remaining));
|
||||
}
|
||||
|
||||
var unspent = monthlyIncome - totalMonthlyOutgo;
|
||||
var unspentPercent = monthlyIncome > 0 ? Math.Round(unspent / monthlyIncome * 100, 2) : 0m;
|
||||
breakdown.Add(new SummaryBreakdownItem("Unspent", null, unspent, unspent * 12m, unspentPercent, null, null));
|
||||
|
||||
var annualOutgoTotal = totalMonthlyOutgo * 12m;
|
||||
var minimumAnnualGross = budget.EffectiveTaxRate < 1m
|
||||
? annualOutgoTotal / (1m - budget.EffectiveTaxRate)
|
||||
: 0m;
|
||||
|
||||
return Ok(new SummaryDto(
|
||||
monthlyIncome,
|
||||
annualIncome,
|
||||
breakdown,
|
||||
new PreTaxIncomeDto(budget.EffectiveTaxRate, minimumAnnualGross, minimumAnnualGross / 12m)));
|
||||
}
|
||||
|
||||
[HttpPut("tax-rate")]
|
||||
public async Task<IActionResult> UpdateTaxRate(Guid budgetId, [FromBody] UpdateTaxRateRequest req)
|
||||
{
|
||||
if (!await authz.CanWriteAsync(budgetId, UserId)) return Forbid();
|
||||
var budget = await db.Budgets.FindAsync(budgetId);
|
||||
if (budget is null) return NotFound();
|
||||
budget.EffectiveTaxRate = req.EffectiveTaxRate;
|
||||
budget.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
namespace Budget.Api.DTOs;
|
||||
|
||||
public record SummaryBreakdownItem(
|
||||
string Type,
|
||||
int? TargetPercent,
|
||||
decimal Total,
|
||||
decimal Annually,
|
||||
decimal Percent,
|
||||
decimal? MaxAmount,
|
||||
decimal? Remaining);
|
||||
|
||||
public record PreTaxIncomeDto(decimal EffectiveTaxRate, decimal MinimumAnnualGross, decimal MinimumMonthlyGross);
|
||||
|
||||
public record SummaryDto(
|
||||
decimal MonthlyIncome,
|
||||
decimal AnnualIncome,
|
||||
List<SummaryBreakdownItem> Breakdown,
|
||||
PreTaxIncomeDto PreTaxIncome);
|
||||
|
||||
public record UpdateTaxRateRequest(decimal EffectiveTaxRate);
|
||||
@@ -1,3 +1,111 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import type { SummaryDto } from '../types';
|
||||
import { api } from '../api/client';
|
||||
import { MoneyDisplay } from '../components/MoneyDisplay';
|
||||
import { BudgetNav } from '../components/BudgetNav';
|
||||
|
||||
const fmt = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' });
|
||||
|
||||
export function SummaryPage() {
|
||||
return <div>Summary — coming soon</div>;
|
||||
const { id: budgetId } = useParams<{ id: string }>();
|
||||
const [summary, setSummary] = useState<SummaryDto | null>(null);
|
||||
const [taxRateInput, setTaxRateInput] = useState('');
|
||||
const [savingTax, setSavingTax] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!budgetId) return;
|
||||
api.get<SummaryDto>(`/api/budgets/${budgetId}/summary`).then(s => {
|
||||
setSummary(s);
|
||||
setTaxRateInput(String(Math.round(s.preTaxIncome.effectiveTaxRate * 100)));
|
||||
});
|
||||
}, [budgetId]);
|
||||
|
||||
const saveTaxRate = async () => {
|
||||
if (!budgetId || !summary) return;
|
||||
setSavingTax(true);
|
||||
const rate = parseFloat(taxRateInput) / 100;
|
||||
await api.put(`/api/budgets/${budgetId}/summary/tax-rate`, { effectiveTaxRate: rate });
|
||||
const updated = await api.get<SummaryDto>(`/api/budgets/${budgetId}/summary`);
|
||||
setSummary(updated);
|
||||
setSavingTax(false);
|
||||
};
|
||||
|
||||
if (!summary) return <div>Loading...</div>;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<BudgetNav />
|
||||
<h1>Summary</h1>
|
||||
|
||||
<div style={{ display: 'flex', gap: '2rem', marginBottom: '1.5rem' }}>
|
||||
<div>
|
||||
<div style={{ fontSize: '0.85rem', color: '#666' }}>Monthly Income</div>
|
||||
<div style={{ fontSize: '1.5rem', fontWeight: 'bold' }}>
|
||||
<MoneyDisplay value={summary.monthlyIncome} />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: '0.85rem', color: '#666' }}>Annual Income</div>
|
||||
<div style={{ fontSize: '1.5rem', fontWeight: 'bold' }}>
|
||||
<MoneyDisplay value={summary.annualIncome} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Type</th>
|
||||
<th>Monthly Total</th>
|
||||
<th>Annual Total</th>
|
||||
<th>% of Income</th>
|
||||
<th>Max Amount (target%)</th>
|
||||
<th>Remaining</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{summary.breakdown.map(row => (
|
||||
<tr key={row.type}>
|
||||
<td>
|
||||
{row.type}
|
||||
{row.targetPercent != null && (
|
||||
<span title={`Target: ${row.targetPercent}%`} style={{ marginLeft: '4px', cursor: 'help', color: '#888' }}>ⓘ</span>
|
||||
)}
|
||||
</td>
|
||||
<td><MoneyDisplay value={row.total} /></td>
|
||||
<td><MoneyDisplay value={row.annually} /></td>
|
||||
<td>{row.percent.toFixed(1)}%</td>
|
||||
<td>{row.maxAmount != null ? fmt.format(row.maxAmount) : '—'}</td>
|
||||
<td>{row.remaining != null ? fmt.format(row.remaining) : '—'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div style={{ marginTop: '2rem' }}>
|
||||
<h2>Pre-Tax Income</h2>
|
||||
<div>
|
||||
<label>
|
||||
Effective Tax Rate (%){' '}
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
max="100"
|
||||
value={taxRateInput}
|
||||
onChange={e => setTaxRateInput(e.target.value)}
|
||||
style={{ width: '60px' }}
|
||||
/>
|
||||
<button onClick={saveTaxRate} disabled={savingTax} style={{ marginLeft: '8px' }}>
|
||||
{savingTax ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
</label>
|
||||
</div>
|
||||
<div style={{ marginTop: '0.75rem' }}>
|
||||
<div>Minimum Annual Gross: <strong><MoneyDisplay value={summary.preTaxIncome.minimumAnnualGross} /></strong></div>
|
||||
<div>Minimum Monthly Gross: <strong><MoneyDisplay value={summary.preTaxIncome.minimumMonthlyGross} /></strong></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user