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