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,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>
);
}