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