45f921bb71
- Add ErrorBoundary component wrapping the whole app - Add ToastProvider with showError/showInfo; Income and Outgo pages use it for API errors - Add LoadingSkeleton component with shimmer animation; Income and Outgo show it while loading - Add confirm-on-delete dialogs for income and outgo rows - Apply EF migrations automatically on startup via MigrateAsync() - Add /healthz health check endpoint using DbContext check - Add Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore package Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
254 lines
8.5 KiB
TypeScript
254 lines
8.5 KiB
TypeScript
import { useEffect, useState } from 'react';
|
|
import { useParams } from 'react-router-dom';
|
|
import { useToast } from '../components/Toast';
|
|
import { LoadingSkeleton } from '../components/LoadingSkeleton';
|
|
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 [loading, setLoading] = useState(true);
|
|
const { showError } = useToast();
|
|
const sensors = useSensors(useSensor(PointerSensor));
|
|
|
|
useEffect(() => {
|
|
if (!budgetId) return;
|
|
setLoading(true);
|
|
Promise.all([
|
|
api.get<OutgoDto[]>(`/api/budgets/${budgetId}/outgos`),
|
|
api.get<string[]>(`/api/budgets/${budgetId}/outgos/categories`),
|
|
api.get<string[]>(`/api/budgets/${budgetId}/outgos/payment-sources`),
|
|
]).then(([o, c, p]) => { setOutgos(o); setCategories(c); setPaymentSources(p); })
|
|
.catch(e => showError(String(e)))
|
|
.finally(() => setLoading(false));
|
|
}, [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) => {
|
|
try {
|
|
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();
|
|
} catch (e) { showError(String(e)); }
|
|
};
|
|
|
|
const handleDelete = async (id: string) => {
|
|
if (!confirm('Delete this outgo row?')) return;
|
|
try {
|
|
await api.delete(`/api/budgets/${budgetId}/outgos/${id}`);
|
|
setOutgos(prev => prev.filter(o => o.id !== id));
|
|
} catch (e) { showError(String(e)); }
|
|
};
|
|
|
|
const handleAdd = async () => {
|
|
try {
|
|
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]);
|
|
} catch (e) { showError(String(e)); }
|
|
};
|
|
|
|
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),
|
|
});
|
|
};
|
|
|
|
if (loading) return <><BudgetNav /><LoadingSkeleton rows={5} cols={9} /></>;
|
|
|
|
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>
|
|
);
|
|
}
|