diff --git a/src/Budget.Client/src/pages/BudgetsPage.tsx b/src/Budget.Client/src/pages/BudgetsPage.tsx index 9522f2c..aab0134 100644 --- a/src/Budget.Client/src/pages/BudgetsPage.tsx +++ b/src/Budget.Client/src/pages/BudgetsPage.tsx @@ -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({ + 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() { ))} -
- setNewName(e.target.value)} - onKeyDown={e => e.key === 'Enter' && handleCreate()} - /> - -
+
+
+
+ + {errors.name && {errors.name.message}} +
+ +
+
); } diff --git a/src/Budget.Client/src/pages/IncomePage.tsx b/src/Budget.Client/src/pages/IncomePage.tsx index 499b961..d05a6fa 100644 --- a/src/Budget.Client/src/pages/IncomePage.tsx +++ b/src/Budget.Client/src/pages/IncomePage.tsx @@ -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(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({ + 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 ( ⠿ - setEditing(p => p && ({ ...p, name: e.target.value }))} /> - setEditing(p => p && ({ ...p, frequency: v }))} + + {errors.name && {errors.name.message}} + + + ( + + )} /> - setEditing(p => p && ({ ...p, amount: e.target.value }))} /> + + + {errors.amount && {errors.amount.message}} + - + @@ -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() { - + ); } diff --git a/src/Budget.Client/src/pages/OutgoPage.tsx b/src/Budget.Client/src/pages/OutgoPage.tsx index 7a187f9..0bc7e0e 100644 --- a/src/Budget.Client/src/pages/OutgoPage.tsx +++ b/src/Budget.Client/src/pages/OutgoPage.tsx @@ -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(null); + const [editing, setEditing] = useState(false); - const set = (patch: Partial) => 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({ + 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 ( ⠿ - set({ name: e.target.value })} /> - set({ category: v })} - suggestions={categories} - placeholder="Category" + + {errors.name && {errors.name.message}} + + + ( + + )} /> - {OUTGO_TYPES.map(t => )} - set({ frequency: v })} /> - set({ amount: e.target.value })} /> - - - - set({ paymentSource: v })} - suggestions={paymentSources} - placeholder="Payment source" + ( + + )} /> - set({ notes: e.target.value })} /> - + + {errors.amount && {errors.amount.message}} + + + + + + ( + + )} + /> + + + + @@ -114,16 +150,16 @@ function SortableRow({ return ( ⠿ - setEditing(toEditState(outgo))}>{outgo.name} - setEditing(toEditState(outgo))}>{outgo.category} - setEditing(toEditState(outgo))}>{outgo.type} - setEditing(toEditState(outgo))}>{outgo.frequency} - setEditing(toEditState(outgo))}> + {outgo.name} + {outgo.category} + {outgo.type} + {outgo.frequency} + {outgo.monthlyPercent.toFixed(1)}% - setEditing(toEditState(outgo))}>{outgo.paymentSource} - setEditing(toEditState(outgo))}>{outgo.notes} + {outgo.paymentSource} + {outgo.notes} ); @@ -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() { - + ); } diff --git a/src/Budget.Client/src/pages/SettingsPage.tsx b/src/Budget.Client/src/pages/SettingsPage.tsx index d0902cd..c5cf326 100644 --- a/src/Budget.Client/src/pages/SettingsPage.tsx +++ b/src/Budget.Client/src/pages/SettingsPage.tsx @@ -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('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({ + resolver: zodResolver(createBudgetSchema), + defaultValues: { name: '' }, + }); + + const taxForm = useForm({ + resolver: zodResolver(updateTaxRateSchema), + defaultValues: { effectiveTaxRate: 0 }, + }); + + const shareForm = useForm({ + 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
Loading...
; @@ -53,24 +71,45 @@ export function SettingsPage() {

Rename Budget

- setNameInput(e.target.value)} /> - +
+
+ + {renameForm.formState.errors.name && ( + + {renameForm.formState.errors.name.message} + + )} +
+ +

Effective Tax Rate

- +
+ + {taxForm.formState.errors.effectiveTaxRate && ( + + {taxForm.formState.errors.effectiveTaxRate.message} + + )} +
@@ -104,18 +143,24 @@ export function SettingsPage() { -
- setShareEmail(e.target.value)} - /> - + {shareForm.formState.errors.email && ( + + {shareForm.formState.errors.email.message} + + )} +
+ - - + +
); diff --git a/src/Budget.Client/src/pages/SummaryPage.tsx b/src/Budget.Client/src/pages/SummaryPage.tsx index fb8447a..4c29746 100644 --- a/src/Budget.Client/src/pages/SummaryPage.tsx +++ b/src/Budget.Client/src/pages/SummaryPage.tsx @@ -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({ + 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
Loading...
; @@ -76,22 +83,30 @@ export function SummaryPage() {

Pre-Tax Income

-
+
-
+ {errors.effectiveTaxRate && ( + + {errors.effectiveTaxRate.message} + + )} +
Minimum Annual Gross:
Minimum Monthly Gross:
diff --git a/src/Budget.Client/src/schemas/index.ts b/src/Budget.Client/src/schemas/index.ts index 7159f4a..f682831 100644 --- a/src/Budget.Client/src/schemas/index.ts +++ b/src/Budget.Client/src/schemas/index.ts @@ -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;