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() {
|
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() {
|
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