Phase 3+4: Wire react-hook-form+zod into all form pages; disable buttons during submit
This commit is contained in:
@@ -1,17 +1,21 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useBudgets, useCreateBudget } from '../api/budgets';
|
||||
import { createBudgetSchema, type CreateBudgetInput } from '../schemas/index';
|
||||
|
||||
export function BudgetsPage() {
|
||||
const [newName, setNewName] = useState('');
|
||||
const navigate = useNavigate();
|
||||
const { data: budgets = [] } = useBudgets();
|
||||
const createBudget = useCreateBudget();
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!newName.trim()) return;
|
||||
const created = await createBudget.mutateAsync({ name: newName.trim() });
|
||||
setNewName('');
|
||||
const { register, handleSubmit, reset, formState: { errors, isSubmitting } } = useForm<CreateBudgetInput>({
|
||||
resolver: zodResolver(createBudgetSchema),
|
||||
});
|
||||
|
||||
const onSubmit = async (data: CreateBudgetInput) => {
|
||||
const created = await createBudget.mutateAsync(data);
|
||||
reset();
|
||||
navigate(`/budgets/${created.id}/income`);
|
||||
};
|
||||
|
||||
@@ -28,15 +32,15 @@ export function BudgetsPage() {
|
||||
</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>
|
||||
<form onSubmit={handleSubmit(onSubmit)} style={{ marginTop: '1rem' }}>
|
||||
<div style={{ display: 'flex', gap: '8px', alignItems: 'flex-start' }}>
|
||||
<div>
|
||||
<input placeholder="Budget name" {...register('name')} />
|
||||
{errors.name && <span style={{ color: 'red', display: 'block', fontSize: '0.85rem' }}>{errors.name.message}</span>}
|
||||
</div>
|
||||
<button type="submit" disabled={isSubmitting || createBudget.isPending}>Create</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { useForm, Controller } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { LoadingSkeleton } from '../components/LoadingSkeleton';
|
||||
import {
|
||||
DndContext,
|
||||
@@ -16,17 +18,12 @@ import {
|
||||
arrayMove,
|
||||
} from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import type { IncomeDto, Frequency } from '../types/index';
|
||||
import type { IncomeDto } from '../types/index';
|
||||
import { FrequencySelect } from '../components/FrequencySelect';
|
||||
import { MoneyDisplay } from '../components/MoneyDisplay';
|
||||
import { BudgetNav } from '../components/BudgetNav';
|
||||
import { useIncomes, useCreateIncome, useUpdateIncome, useDeleteIncome, useReorderIncomes } from '../api/incomes';
|
||||
|
||||
interface EditState {
|
||||
name: string;
|
||||
frequency: Frequency;
|
||||
amount: string;
|
||||
}
|
||||
import { createIncomeSchema, type CreateIncomeInput } from '../schemas/index';
|
||||
|
||||
function SortableRow({
|
||||
income,
|
||||
@@ -34,38 +31,55 @@ function SortableRow({
|
||||
onDelete,
|
||||
}: {
|
||||
income: IncomeDto;
|
||||
onSave: (id: string, edit: EditState) => void;
|
||||
onSave: (id: string, data: CreateIncomeInput) => void;
|
||||
onDelete: (id: string) => void;
|
||||
}) {
|
||||
const { attributes, listeners, setNodeRef, transform, transition } = useSortable({ id: income.id });
|
||||
const style = { transform: CSS.Transform.toString(transform), transition };
|
||||
const [editing, setEditing] = useState<EditState | null>(null);
|
||||
const [editing, setEditing] = useState(false);
|
||||
|
||||
const startEdit = () =>
|
||||
setEditing({ name: income.name, frequency: income.frequency, amount: String(income.amount) });
|
||||
const { register, handleSubmit, reset, control, formState: { errors, isSubmitting } } = useForm<CreateIncomeInput>({
|
||||
resolver: zodResolver(createIncomeSchema),
|
||||
defaultValues: { name: income.name, frequency: income.frequency, amount: income.amount },
|
||||
});
|
||||
|
||||
const save = () => {
|
||||
if (editing) { onSave(income.id, editing); setEditing(null); }
|
||||
const startEdit = () => {
|
||||
reset({ name: income.name, frequency: income.frequency, amount: income.amount });
|
||||
setEditing(true);
|
||||
};
|
||||
|
||||
const cancel = () => setEditing(null);
|
||||
const onSubmit = (data: CreateIncomeInput) => {
|
||||
onSave(income.id, data);
|
||||
setEditing(false);
|
||||
};
|
||||
|
||||
const cancel = () => setEditing(false);
|
||||
|
||||
if (editing) {
|
||||
return (
|
||||
<tr ref={setNodeRef} style={style}>
|
||||
<td>⠿</td>
|
||||
<td><input value={editing.name} onChange={e => setEditing(p => p && ({ ...p, name: e.target.value }))} /></td>
|
||||
<td>
|
||||
<FrequencySelect
|
||||
value={editing.frequency}
|
||||
onChange={v => setEditing(p => p && ({ ...p, frequency: v }))}
|
||||
<input {...register('name')} />
|
||||
{errors.name && <span style={{ color: 'red', fontSize: '0.8rem' }}>{errors.name.message}</span>}
|
||||
</td>
|
||||
<td>
|
||||
<Controller
|
||||
control={control}
|
||||
name="frequency"
|
||||
render={({ field }) => (
|
||||
<FrequencySelect value={field.value} onChange={field.onChange} />
|
||||
)}
|
||||
/>
|
||||
</td>
|
||||
<td><input type="number" value={editing.amount} onChange={e => setEditing(p => p && ({ ...p, amount: e.target.value }))} /></td>
|
||||
<td>
|
||||
<input type="number" step="0.01" {...register('amount', { valueAsNumber: true })} />
|
||||
{errors.amount && <span style={{ color: 'red', fontSize: '0.8rem' }}>{errors.amount.message}</span>}
|
||||
</td>
|
||||
<td></td>
|
||||
<td></td>
|
||||
<td>
|
||||
<button onClick={save}>Save</button>
|
||||
<button onClick={handleSubmit(onSubmit)} disabled={isSubmitting}>Save</button>
|
||||
<button onClick={cancel}>Cancel</button>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -98,8 +112,8 @@ export function IncomePage() {
|
||||
const createIncome = useCreateIncome(budgetId!);
|
||||
const reorderIncomes = useReorderIncomes(budgetId!);
|
||||
|
||||
const handleSave = (id: string, edit: EditState) => {
|
||||
updateIncome.mutate({ id, name: edit.name, frequency: edit.frequency, amount: parseFloat(edit.amount) });
|
||||
const handleSave = (id: string, data: CreateIncomeInput) => {
|
||||
updateIncome.mutate({ id, name: data.name, frequency: data.frequency, amount: data.amount });
|
||||
};
|
||||
|
||||
const handleDelete = (id: string) => {
|
||||
@@ -154,7 +168,7 @@ export function IncomePage() {
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
</table>
|
||||
<button onClick={handleAdd}>+ Add Row</button>
|
||||
<button onClick={handleAdd} disabled={createIncome.isPending}>+ Add Row</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { useForm, Controller } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { LoadingSkeleton } from '../components/LoadingSkeleton';
|
||||
import {
|
||||
DndContext,
|
||||
@@ -16,7 +18,7 @@ import {
|
||||
arrayMove,
|
||||
} from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import type { OutgoDto, Frequency, OutgoType } from '../types/index';
|
||||
import type { OutgoDto } from '../types/index';
|
||||
import { FrequencySelect } from '../components/FrequencySelect';
|
||||
import { MoneyDisplay } from '../components/MoneyDisplay';
|
||||
import { AutocompleteInput } from '../components/AutocompleteInput';
|
||||
@@ -25,30 +27,9 @@ import {
|
||||
useOutgos, useCategories, usePaymentSources,
|
||||
useCreateOutgo, useUpdateOutgo, useDeleteOutgo, useReorderOutgos,
|
||||
} from '../api/outgos';
|
||||
import { createOutgoSchema, type CreateOutgoInput } from '../schemas/index';
|
||||
|
||||
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 ?? '',
|
||||
};
|
||||
}
|
||||
const OUTGO_TYPES = ['Need', 'Want', 'Save'] as const;
|
||||
|
||||
function SortableRow({
|
||||
outgo,
|
||||
@@ -60,51 +41,106 @@ function SortableRow({
|
||||
outgo: OutgoDto;
|
||||
categories: string[];
|
||||
paymentSources: string[];
|
||||
onSave: (id: string, edit: EditState) => void;
|
||||
onSave: (id: string, data: CreateOutgoInput) => 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 [editing, setEditing] = useState(false);
|
||||
|
||||
const set = (patch: Partial<EditState>) => setEditing(p => p && ({ ...p, ...patch }));
|
||||
const save = () => { if (editing) { onSave(outgo.id, editing); setEditing(null); } };
|
||||
const cancel = () => setEditing(null);
|
||||
const { register, handleSubmit, reset, control, formState: { errors, isSubmitting } } = useForm<CreateOutgoInput>({
|
||||
resolver: zodResolver(createOutgoSchema),
|
||||
defaultValues: {
|
||||
name: outgo.name,
|
||||
category: outgo.category ?? '',
|
||||
type: outgo.type,
|
||||
frequency: outgo.frequency,
|
||||
amount: outgo.amount,
|
||||
paymentSource: outgo.paymentSource ?? '',
|
||||
notes: outgo.notes ?? '',
|
||||
},
|
||||
});
|
||||
|
||||
const startEdit = () => {
|
||||
reset({
|
||||
name: outgo.name,
|
||||
category: outgo.category ?? '',
|
||||
type: outgo.type,
|
||||
frequency: outgo.frequency,
|
||||
amount: outgo.amount,
|
||||
paymentSource: outgo.paymentSource ?? '',
|
||||
notes: outgo.notes ?? '',
|
||||
});
|
||||
setEditing(true);
|
||||
};
|
||||
|
||||
const onSubmit = (data: CreateOutgoInput) => {
|
||||
onSave(outgo.id, data);
|
||||
setEditing(false);
|
||||
};
|
||||
|
||||
const cancel = () => setEditing(false);
|
||||
|
||||
if (editing) {
|
||||
return (
|
||||
<tr ref={setNodeRef} style={style}>
|
||||
<td>⠿</td>
|
||||
<td><input value={editing.name} onChange={e => set({ name: e.target.value })} /></td>
|
||||
<td>
|
||||
<input {...register('name')} />
|
||||
{errors.name && <span style={{ color: 'red', fontSize: '0.8rem' }}>{errors.name.message}</span>}
|
||||
</td>
|
||||
<td>
|
||||
<Controller
|
||||
control={control}
|
||||
name="category"
|
||||
render={({ field }) => (
|
||||
<AutocompleteInput
|
||||
value={editing.category}
|
||||
onChange={v => set({ category: v })}
|
||||
value={field.value ?? ''}
|
||||
onChange={field.onChange}
|
||||
suggestions={categories}
|
||||
placeholder="Category"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<select value={editing.type} onChange={e => set({ type: e.target.value as OutgoType })}>
|
||||
<select {...register('type')}>
|
||||
{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>
|
||||
<Controller
|
||||
control={control}
|
||||
name="frequency"
|
||||
render={({ field }) => (
|
||||
<FrequencySelect value={field.value} onChange={field.onChange} />
|
||||
)}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<input type="number" step="0.01" {...register('amount', { valueAsNumber: true })} />
|
||||
{errors.amount && <span style={{ color: 'red', fontSize: '0.8rem' }}>{errors.amount.message}</span>}
|
||||
</td>
|
||||
<td></td>
|
||||
<td></td>
|
||||
<td></td>
|
||||
<td>
|
||||
<Controller
|
||||
control={control}
|
||||
name="paymentSource"
|
||||
render={({ field }) => (
|
||||
<AutocompleteInput
|
||||
value={editing.paymentSource}
|
||||
onChange={v => set({ paymentSource: v })}
|
||||
value={field.value ?? ''}
|
||||
onChange={field.onChange}
|
||||
suggestions={paymentSources}
|
||||
placeholder="Payment source"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</td>
|
||||
<td><input value={editing.notes} onChange={e => set({ notes: e.target.value })} /></td>
|
||||
<td><input {...register('notes')} /></td>
|
||||
<td>
|
||||
<button onClick={save}>Save</button>
|
||||
<button onClick={handleSubmit(onSubmit)} disabled={isSubmitting}>Save</button>
|
||||
<button onClick={cancel}>Cancel</button>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -114,16 +150,16 @@ function SortableRow({
|
||||
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 onClick={startEdit}>{outgo.name}</td>
|
||||
<td onClick={startEdit}>{outgo.category}</td>
|
||||
<td onClick={startEdit}>{outgo.type}</td>
|
||||
<td onClick={startEdit}>{outgo.frequency}</td>
|
||||
<td onClick={startEdit}><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 onClick={startEdit}>{outgo.paymentSource}</td>
|
||||
<td onClick={startEdit}>{outgo.notes}</td>
|
||||
<td><button onClick={() => onDelete(outgo.id)}>Delete</button></td>
|
||||
</tr>
|
||||
);
|
||||
@@ -144,16 +180,16 @@ export function OutgoPage() {
|
||||
const createOutgo = useCreateOutgo(budgetId!);
|
||||
const reorderOutgos = useReorderOutgos(budgetId!);
|
||||
|
||||
const handleSave = (id: string, edit: EditState) => {
|
||||
const handleSave = (id: string, data: CreateOutgoInput) => {
|
||||
updateOutgo.mutate({
|
||||
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,
|
||||
name: data.name,
|
||||
category: data.category || null,
|
||||
type: data.type,
|
||||
frequency: data.frequency,
|
||||
amount: data.amount,
|
||||
paymentSource: data.paymentSource || null,
|
||||
notes: data.notes || null,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -226,7 +262,7 @@ export function OutgoPage() {
|
||||
</DndContext>
|
||||
</table>
|
||||
</div>
|
||||
<button onClick={handleAdd}>+ Add Row</button>
|
||||
<button onClick={handleAdd} disabled={createOutgo.isPending}>+ Add Row</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,47 +1,65 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useEffect } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import type { SharePermission } from '../types/index';
|
||||
import { BudgetNav } from '../components/BudgetNav';
|
||||
import { useBudget, useUpdateBudget } from '../api/budgets';
|
||||
import { useUpdateTaxRate } from '../api/summary';
|
||||
import { useShares, useAddShare, useUpdateShare, useRevokeShare } from '../api/shares';
|
||||
import {
|
||||
createBudgetSchema,
|
||||
updateTaxRateSchema,
|
||||
createShareSchema,
|
||||
type CreateBudgetInput,
|
||||
type UpdateTaxRateInput,
|
||||
type CreateShareInput,
|
||||
} from '../schemas/index';
|
||||
|
||||
export function SettingsPage() {
|
||||
const { id: budgetId } = useParams<{ id: string }>();
|
||||
const { data: budget } = useBudget(budgetId!);
|
||||
const { data: shares = [] } = useShares(budgetId!);
|
||||
|
||||
const [nameInput, setNameInput] = useState('');
|
||||
const [taxInput, setTaxInput] = useState('');
|
||||
const [shareEmail, setShareEmail] = useState('');
|
||||
const [sharePermission, setSharePermission] = useState<SharePermission>('View');
|
||||
|
||||
useEffect(() => {
|
||||
if (budget) {
|
||||
setNameInput(budget.name);
|
||||
setTaxInput(String(Math.round(budget.effectiveTaxRate * 100)));
|
||||
}
|
||||
}, [budget]);
|
||||
|
||||
const updateBudget = useUpdateBudget(budgetId!);
|
||||
const updateTaxRate = useUpdateTaxRate(budgetId!);
|
||||
const addShare = useAddShare(budgetId!);
|
||||
const updateShare = useUpdateShare(budgetId!);
|
||||
const revokeShare = useRevokeShare(budgetId!);
|
||||
|
||||
const saveName = () => {
|
||||
if (!nameInput.trim()) return;
|
||||
updateBudget.mutate({ name: nameInput.trim() });
|
||||
const renameForm = useForm<CreateBudgetInput>({
|
||||
resolver: zodResolver(createBudgetSchema),
|
||||
defaultValues: { name: '' },
|
||||
});
|
||||
|
||||
const taxForm = useForm<UpdateTaxRateInput>({
|
||||
resolver: zodResolver(updateTaxRateSchema),
|
||||
defaultValues: { effectiveTaxRate: 0 },
|
||||
});
|
||||
|
||||
const shareForm = useForm<CreateShareInput>({
|
||||
resolver: zodResolver(createShareSchema),
|
||||
defaultValues: { email: '', permission: 'View' },
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (budget) {
|
||||
renameForm.reset({ name: budget.name });
|
||||
taxForm.reset({ effectiveTaxRate: Math.round(budget.effectiveTaxRate * 100) });
|
||||
}
|
||||
}, [budget]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const onRename = async (data: CreateBudgetInput) => {
|
||||
await updateBudget.mutateAsync({ name: data.name });
|
||||
};
|
||||
|
||||
const saveTaxRate = () => {
|
||||
updateTaxRate.mutate(parseFloat(taxInput) / 100);
|
||||
const onSaveTaxRate = async (data: UpdateTaxRateInput) => {
|
||||
await updateTaxRate.mutateAsync(data.effectiveTaxRate / 100);
|
||||
};
|
||||
|
||||
const handleAddShare = () => {
|
||||
if (!shareEmail.trim()) return;
|
||||
addShare.mutate({ email: shareEmail.trim(), permission: sharePermission });
|
||||
setShareEmail('');
|
||||
const onAddShare = async (data: CreateShareInput) => {
|
||||
await addShare.mutateAsync({ email: data.email, permission: data.permission });
|
||||
shareForm.reset({ email: '', permission: 'View' });
|
||||
};
|
||||
|
||||
if (!budget) return <div>Loading...</div>;
|
||||
@@ -53,24 +71,45 @@ export function SettingsPage() {
|
||||
|
||||
<section style={{ marginBottom: '2rem' }}>
|
||||
<h2>Rename Budget</h2>
|
||||
<input value={nameInput} onChange={e => setNameInput(e.target.value)} />
|
||||
<button onClick={saveName} disabled={updateBudget.isPending} style={{ marginLeft: '8px' }}>Save</button>
|
||||
<form onSubmit={renameForm.handleSubmit(onRename)} style={{ display: 'flex', gap: '8px', alignItems: 'flex-start' }}>
|
||||
<div>
|
||||
<input {...renameForm.register('name')} />
|
||||
{renameForm.formState.errors.name && (
|
||||
<span style={{ color: 'red', display: 'block', fontSize: '0.85rem' }}>
|
||||
{renameForm.formState.errors.name.message}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<button type="submit" disabled={renameForm.formState.isSubmitting || updateBudget.isPending}>Save</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section style={{ marginBottom: '2rem' }}>
|
||||
<h2>Effective Tax Rate</h2>
|
||||
<form onSubmit={taxForm.handleSubmit(onSaveTaxRate)}>
|
||||
<label>
|
||||
Rate (%){' '}
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
max="100"
|
||||
value={taxInput}
|
||||
onChange={e => setTaxInput(e.target.value)}
|
||||
max="99"
|
||||
{...taxForm.register('effectiveTaxRate', { valueAsNumber: true })}
|
||||
style={{ width: '60px' }}
|
||||
/>
|
||||
<button onClick={saveTaxRate} disabled={updateTaxRate.isPending} style={{ marginLeft: '8px' }}>Save</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={taxForm.formState.isSubmitting || updateTaxRate.isPending}
|
||||
style={{ marginLeft: '8px' }}
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</label>
|
||||
{taxForm.formState.errors.effectiveTaxRate && (
|
||||
<span style={{ color: 'red', display: 'block', fontSize: '0.85rem' }}>
|
||||
{taxForm.formState.errors.effectiveTaxRate.message}
|
||||
</span>
|
||||
)}
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
@@ -104,18 +143,24 @@ export function SettingsPage() {
|
||||
</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)}>
|
||||
<form
|
||||
onSubmit={shareForm.handleSubmit(onAddShare)}
|
||||
style={{ marginTop: '1rem', display: 'flex', gap: '8px', alignItems: 'flex-start' }}
|
||||
>
|
||||
<div>
|
||||
<input placeholder="Email address" {...shareForm.register('email')} />
|
||||
{shareForm.formState.errors.email && (
|
||||
<span style={{ color: 'red', display: 'block', fontSize: '0.85rem' }}>
|
||||
{shareForm.formState.errors.email.message}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<select {...shareForm.register('permission')}>
|
||||
<option value="View">View</option>
|
||||
<option value="Edit">Edit</option>
|
||||
</select>
|
||||
<button onClick={handleAddShare}>Add Share</button>
|
||||
</div>
|
||||
<button type="submit" disabled={shareForm.formState.isSubmitting || addShare.isPending}>Add Share</button>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,25 +1,32 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useEffect } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { MoneyDisplay } from '../components/MoneyDisplay';
|
||||
import { BudgetNav } from '../components/BudgetNav';
|
||||
import { useSummary, useUpdateTaxRate } from '../api/summary';
|
||||
import { updateTaxRateSchema, type UpdateTaxRateInput } from '../schemas/index';
|
||||
|
||||
const fmt = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' });
|
||||
|
||||
export function SummaryPage() {
|
||||
const { id: budgetId } = useParams<{ id: string }>();
|
||||
const { data: summary } = useSummary(budgetId!);
|
||||
const [taxRateInput, setTaxRateInput] = useState('');
|
||||
const updateTaxRate = useUpdateTaxRate(budgetId!);
|
||||
|
||||
const { register, handleSubmit, reset, formState: { errors, isSubmitting } } = useForm<UpdateTaxRateInput>({
|
||||
resolver: zodResolver(updateTaxRateSchema),
|
||||
defaultValues: { effectiveTaxRate: 0 },
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (summary) {
|
||||
setTaxRateInput(String(Math.round(summary.preTaxIncome.effectiveTaxRate * 100)));
|
||||
reset({ effectiveTaxRate: Math.round(summary.preTaxIncome.effectiveTaxRate * 100) });
|
||||
}
|
||||
}, [summary]);
|
||||
}, [summary]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const saveTaxRate = () => {
|
||||
updateTaxRate.mutate(parseFloat(taxRateInput) / 100);
|
||||
const onSaveTaxRate = async (data: UpdateTaxRateInput) => {
|
||||
await updateTaxRate.mutateAsync(data.effectiveTaxRate / 100);
|
||||
};
|
||||
|
||||
if (!summary) return <div>Loading...</div>;
|
||||
@@ -76,22 +83,30 @@ export function SummaryPage() {
|
||||
|
||||
<div style={{ marginTop: '2rem' }}>
|
||||
<h2>Pre-Tax Income</h2>
|
||||
<div>
|
||||
<form onSubmit={handleSubmit(onSaveTaxRate)}>
|
||||
<label>
|
||||
Effective Tax Rate (%){' '}
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
max="100"
|
||||
value={taxRateInput}
|
||||
onChange={e => setTaxRateInput(e.target.value)}
|
||||
max="99"
|
||||
{...register('effectiveTaxRate', { valueAsNumber: true })}
|
||||
style={{ width: '60px' }}
|
||||
/>
|
||||
<button onClick={saveTaxRate} disabled={updateTaxRate.isPending} style={{ marginLeft: '8px' }}>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting || updateTaxRate.isPending}
|
||||
style={{ marginLeft: '8px' }}
|
||||
>
|
||||
{updateTaxRate.isPending ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
</label>
|
||||
</div>
|
||||
{errors.effectiveTaxRate && (
|
||||
<span style={{ color: 'red', display: 'block', fontSize: '0.85rem' }}>
|
||||
{errors.effectiveTaxRate.message}
|
||||
</span>
|
||||
)}
|
||||
</form>
|
||||
<div style={{ marginTop: '0.75rem' }}>
|
||||
<div>Minimum Annual Gross: <strong><MoneyDisplay value={summary.preTaxIncome.minimumAnnualGross} /></strong></div>
|
||||
<div>Minimum Monthly Gross: <strong><MoneyDisplay value={summary.preTaxIncome.minimumMonthlyGross} /></strong></div>
|
||||
|
||||
@@ -19,7 +19,8 @@ export const createBudgetSchema = z.object({
|
||||
export const createIncomeSchema = z.object({
|
||||
name: z.string().min(1, 'Name is required').max(200),
|
||||
frequency: z.enum(frequencyValues),
|
||||
amount: z.coerce.number({ error: 'Amount is required' }).positive('Must be positive'),
|
||||
// Use z.number() (not coerce) so react-hook-form's valueAsNumber handles the string→number conversion
|
||||
amount: z.number({ error: 'Amount is required' }).positive('Must be positive'),
|
||||
});
|
||||
|
||||
export const createOutgoSchema = z.object({
|
||||
@@ -27,7 +28,7 @@ export const createOutgoSchema = z.object({
|
||||
category: z.string().max(100).optional(),
|
||||
type: z.enum(['Need', 'Want', 'Save']),
|
||||
frequency: z.enum(frequencyValues),
|
||||
amount: z.coerce.number().positive('Must be positive'),
|
||||
amount: z.number({ error: 'Amount is required' }).positive('Must be positive'),
|
||||
paymentSource: z.string().max(100).optional(),
|
||||
notes: z.string().max(1000).optional(),
|
||||
});
|
||||
@@ -37,8 +38,9 @@ export const createShareSchema = z.object({
|
||||
permission: z.enum(['View', 'Edit']),
|
||||
});
|
||||
|
||||
// UI shows percent (0-99); callers divide by 100 before sending to API
|
||||
export const updateTaxRateSchema = z.object({
|
||||
effectiveTaxRate: z.coerce.number().min(0).max(0.99),
|
||||
effectiveTaxRate: z.number().min(0, 'Must be ≥ 0').max(99, 'Must be less than 100'),
|
||||
});
|
||||
|
||||
export type CreateBudgetInput = z.infer<typeof createBudgetSchema>;
|
||||
|
||||
Reference in New Issue
Block a user