Phase 7: Budget list, settings, and sharing UI

- Implement BudgetsPage: list all budgets, create new budget, navigate to income view
- Implement SettingsPage: rename budget, edit tax rate, manage shares table
- Shares table shows permission dropdowns, pending status badge, revoke button
- Add share form: email input, permission selector, add button
- BudgetNav component used across all budget pages

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Spencer Twaddle
2026-04-25 07:59:40 -05:00
parent 69d6ac0bea
commit 64203606a6
2 changed files with 185 additions and 2 deletions
+45 -1
View File
@@ -1,3 +1,47 @@
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import type { BudgetDto } from '../types';
import { api } from '../api/client';
export function BudgetsPage() {
return <div>Budgets coming soon</div>;
const [budgets, setBudgets] = useState<BudgetDto[]>([]);
const [newName, setNewName] = useState('');
const navigate = useNavigate();
useEffect(() => {
api.get<BudgetDto[]>('/api/budgets').then(setBudgets);
}, []);
const handleCreate = async () => {
if (!newName.trim()) return;
const created = await api.post<BudgetDto>('/api/budgets', { name: newName.trim() });
setBudgets(prev => [...prev, created]);
setNewName('');
navigate(`/budgets/${created.id}/income`);
};
return (
<div>
<h1>My Budgets</h1>
{budgets.length === 0 && <p>No budgets yet. Create one below.</p>}
<ul>
{budgets.map(b => (
<li key={b.id}>
<button onClick={() => navigate(`/budgets/${b.id}/income`)}>
{b.name}
</button>
</li>
))}
</ul>
<div style={{ marginTop: '1rem' }}>
<input
placeholder="Budget name"
value={newName}
onChange={e => setNewName(e.target.value)}
onKeyDown={e => e.key === 'Enter' && handleCreate()}
/>
<button onClick={handleCreate} style={{ marginLeft: '8px' }}>Create</button>
</div>
</div>
);
}
+140 -1
View File
@@ -1,3 +1,142 @@
import { useEffect, useState } from 'react';
import { useParams } from 'react-router-dom';
import type { BudgetDto, ShareDto, SharePermission } from '../types';
import { api } from '../api/client';
import { BudgetNav } from '../components/BudgetNav';
export function SettingsPage() {
return <div>Settings coming soon</div>;
const { id: budgetId } = useParams<{ id: string }>();
const [budget, setBudget] = useState<BudgetDto | null>(null);
const [shares, setShares] = useState<ShareDto[]>([]);
const [nameInput, setNameInput] = useState('');
const [taxInput, setTaxInput] = useState('');
const [shareEmail, setShareEmail] = useState('');
const [sharePermission, setSharePermission] = useState<SharePermission>('View');
const [saving, setSaving] = useState(false);
useEffect(() => {
if (!budgetId) return;
api.get<BudgetDto>(`/api/budgets/${budgetId}`).then(b => {
setBudget(b);
setNameInput(b.name);
setTaxInput(String(Math.round(b.effectiveTaxRate * 100)));
});
api.get<ShareDto[]>(`/api/budgets/${budgetId}/shares`).then(setShares);
}, [budgetId]);
const saveName = async () => {
if (!budgetId || !nameInput.trim()) return;
setSaving(true);
const updated = await api.put<BudgetDto>(`/api/budgets/${budgetId}`, { name: nameInput.trim() });
setBudget(updated);
setSaving(false);
};
const saveTaxRate = async () => {
if (!budgetId) return;
setSaving(true);
const rate = parseFloat(taxInput) / 100;
await api.put(`/api/budgets/${budgetId}/summary/tax-rate`, { effectiveTaxRate: rate });
const updated = await api.get<BudgetDto>(`/api/budgets/${budgetId}`);
setBudget(updated);
setSaving(false);
};
const addShare = async () => {
if (!budgetId || !shareEmail.trim()) return;
const share = await api.post<ShareDto>(`/api/budgets/${budgetId}/shares`, {
email: shareEmail.trim(),
permission: sharePermission,
});
setShares(prev => [...prev, share]);
setShareEmail('');
};
const updatePermission = async (shareId: string, permission: SharePermission) => {
if (!budgetId) return;
const updated = await api.put<ShareDto>(`/api/budgets/${budgetId}/shares/${shareId}`, { permission });
setShares(prev => prev.map(s => s.id === shareId ? updated : s));
};
const revokeShare = async (shareId: string) => {
if (!budgetId) return;
await api.delete(`/api/budgets/${budgetId}/shares/${shareId}`);
setShares(prev => prev.filter(s => s.id !== shareId));
};
if (!budget) return <div>Loading...</div>;
return (
<div>
<BudgetNav />
<h1>Settings</h1>
<section style={{ marginBottom: '2rem' }}>
<h2>Rename Budget</h2>
<input value={nameInput} onChange={e => setNameInput(e.target.value)} />
<button onClick={saveName} disabled={saving} style={{ marginLeft: '8px' }}>Save</button>
</section>
<section style={{ marginBottom: '2rem' }}>
<h2>Effective Tax Rate</h2>
<label>
Rate (%){' '}
<input
type="number"
min="0"
max="100"
value={taxInput}
onChange={e => setTaxInput(e.target.value)}
style={{ width: '60px' }}
/>
<button onClick={saveTaxRate} disabled={saving} style={{ marginLeft: '8px' }}>Save</button>
</label>
</section>
<section>
<h2>Sharing</h2>
<table>
<thead>
<tr>
<th>Email</th>
<th>Permission</th>
<th>Status</th>
<th></th>
</tr>
</thead>
<tbody>
{shares.map(s => (
<tr key={s.id}>
<td>{s.sharedWithEmail}</td>
<td>
<select
value={s.permission}
onChange={e => updatePermission(s.id, e.target.value as SharePermission)}
>
<option value="View">View</option>
<option value="Edit">Edit</option>
</select>
</td>
<td>{s.isPending ? <em>Pending</em> : 'Active'}</td>
<td><button onClick={() => revokeShare(s.id)}>Revoke</button></td>
</tr>
))}
</tbody>
</table>
<div style={{ marginTop: '1rem', display: 'flex', gap: '8px', alignItems: 'center' }}>
<input
placeholder="Email address"
value={shareEmail}
onChange={e => setShareEmail(e.target.value)}
/>
<select value={sharePermission} onChange={e => setSharePermission(e.target.value as SharePermission)}>
<option value="View">View</option>
<option value="Edit">Edit</option>
</select>
<button onClick={addShare}>Add Share</button>
</div>
</section>
</div>
);
}