Phase 3+4: Wire react-hook-form+zod into all form pages; disable buttons during submit

This commit is contained in:
Spencer Twaddle
2026-05-02 17:09:31 -05:00
parent 95665e5baa
commit da6eb547ce
6 changed files with 278 additions and 162 deletions
+19 -15
View File
@@ -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>
</div>
<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>
);
}
+37 -23
View File
@@ -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>
);
}
+100 -64
View File
@@ -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>
<AutocompleteInput
value={editing.category}
onChange={v => set({ category: v })}
suggestions={categories}
placeholder="Category"
<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={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></td>
<td></td>
<td></td>
<td>
<AutocompleteInput
value={editing.paymentSource}
onChange={v => set({ paymentSource: v })}
suggestions={paymentSources}
placeholder="Payment source"
<Controller
control={control}
name="frequency"
render={({ field }) => (
<FrequencySelect value={field.value} onChange={field.onChange} />
)}
/>
</td>
<td><input value={editing.notes} onChange={e => set({ notes: e.target.value })} /></td>
<td>
<button onClick={save}>Save</button>
<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={field.value ?? ''}
onChange={field.onChange}
suggestions={paymentSources}
placeholder="Payment source"
/>
)}
/>
</td>
<td><input {...register('notes')} /></td>
<td>
<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>
);
}
+90 -45
View File
@@ -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>
<label>
Rate (%){' '}
<input
type="number"
min="0"
max="100"
value={taxInput}
onChange={e => setTaxInput(e.target.value)}
style={{ width: '60px' }}
/>
<button onClick={saveTaxRate} disabled={updateTaxRate.isPending} style={{ marginLeft: '8px' }}>Save</button>
</label>
<form onSubmit={taxForm.handleSubmit(onSaveTaxRate)}>
<label>
Rate (%){' '}
<input
type="number"
min="0"
max="99"
{...taxForm.register('effectiveTaxRate', { valueAsNumber: true })}
style={{ width: '60px' }}
/>
<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>
);
+27 -12
View File
@@ -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>
+5 -3
View File
@@ -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>;