diff --git a/frontend/src/pages/EmployeeEntry.tsx b/frontend/src/pages/EmployeeEntry.tsx index 0384e061..2cf168ec 100644 --- a/frontend/src/pages/EmployeeEntry.tsx +++ b/frontend/src/pages/EmployeeEntry.tsx @@ -7,6 +7,7 @@ import { useAutosave } from '../hooks/useAutosave'; import { generateWallet } from '../services/stellar'; import { useTranslation } from 'react-i18next'; import { useNotification } from '../hooks/useNotification'; +import { employeeFormSchema, type EmployeeFormData } from '../schemas'; import api from '../utils/api'; @@ -36,9 +37,14 @@ const initialFormState: EmployeeFormState = { email: '', }; +type FieldErrors = Partial>; +const EMPTY_ERRORS: FieldErrors = {}; + export default function EmployeeEntry() { const [isAdding, setIsAdding] = useState(false); const [formData, setFormData] = useState(initialFormState); + const [fieldErrors, setFieldErrors] = useState(EMPTY_ERRORS); + const [touched, setTouched] = useState>>({}); const [employees, setEmployees] = useState([]); const [loading, setLoading] = useState(false); const [notification, setNotification] = useState<{ @@ -108,12 +114,63 @@ export default function EmployeeEntry() { setFormData((prev: EmployeeFormState) => ({ ...prev, [name]: value })); }; + const validateField = (name: keyof EmployeeFormData, value: string) => { + const result = employeeFormSchema.safeParse({ ...formData, [name]: value }); + if (!result.success) { + const fieldIssue = result.error.issues.find((i) => i.path[0] === name); + setFieldErrors((prev) => ({ ...prev, [name]: fieldIssue?.message ?? '' })); + } else { + setFieldErrors((prev) => ({ ...prev, [name]: undefined })); + } + }; + + const handleBlur = ( + e: React.FocusEvent + ) => { + const { name, value } = e.target; + setTouched((prev) => ({ ...prev, [name]: true })); + validateField(name as keyof EmployeeFormData, value); + }; + + const isFormValid = () => { + // Pure check used only for disabling the submit button — no side effects. + return employeeFormSchema.safeParse(formData).success; + }; + + const validateFormState = (): boolean => { + const result = employeeFormSchema.safeParse(formData); + if (!result.success) { + const errors: FieldErrors = {}; + for (const issue of result.error.issues) { + const field = issue.path[0] as keyof EmployeeFormData; + if (!errors[field]) { + errors[field] = issue.message; + } + } + setFieldErrors(errors); + // Mark all fields as touched + const allTouched: Partial> = {}; + for (const key of Object.keys(initialFormState) as (keyof EmployeeFormData)[]) { + allTouched[key] = true; + } + setTouched(allTouched); + return false; + } + setFieldErrors(EMPTY_ERRORS); + return true; + }; + const handleSelectChange = (name: string, value: string) => { setFormData((prev: EmployeeFormState) => ({ ...prev, [name]: value })); + setTouched((prev) => ({ ...prev, [name]: true })); + validateField(name as keyof EmployeeFormData, value); }; const handleSubmit = async (e: React.SyntheticEvent) => { e.preventDefault(); + if (!validateFormState()) { + return; + } let generatedWallet: { publicKey: string; secretKey: string } | undefined; if (!formData.walletAddress) { generatedWallet = generateWallet(); @@ -262,9 +319,13 @@ export default function EmployeeEntry() { name="fullName" value={formData.fullName} onChange={handleChange} + onBlur={handleBlur} placeholder="Jane Smith" required /> + {touched.fullName && fieldErrors.fullName && ( + {fieldErrors.fullName} + )} + {touched.email && fieldErrors.email && ( + {fieldErrors.email} + )} + {touched.walletAddress && fieldErrors.walletAddress && ( + {fieldErrors.walletAddress} + )} + {touched.role && fieldErrors.role && ( + {fieldErrors.role} + )} - diff --git a/frontend/src/pages/PayrollScheduler.tsx b/frontend/src/pages/PayrollScheduler.tsx index ee67b889..d489a559 100644 --- a/frontend/src/pages/PayrollScheduler.tsx +++ b/frontend/src/pages/PayrollScheduler.tsx @@ -17,6 +17,7 @@ import { ScheduleRecord, } from '../services/scheduleApi'; import { BulkPaymentStatusTracker } from '../components/BulkPaymentStatusTracker'; +import { payrollFormSchema, type PayrollFormData } from '../schemas'; interface EmployeePreference { id: string; @@ -73,6 +74,10 @@ const initialFormState: PayrollFormState = { memo: '', }; +type PayrollFieldErrors = Partial>; +const EMPTY_PAYROLL_ERRORS: PayrollFieldErrors = {}; +type PayrollTouched = Partial>; + export default function PayrollScheduler() { const { t } = useTranslation(); const { notifySuccess, notifyError } = useNotification(); @@ -80,6 +85,8 @@ export default function PayrollScheduler() { const { socket, subscribeToTransaction, unsubscribeFromTransaction } = socketContext; const [formData, setFormData] = useState(initialFormState); + const [fieldErrors, setFieldErrors] = useState(EMPTY_PAYROLL_ERRORS); + const [touched, setTouched] = useState({}); const [isBroadcasting, setIsBroadcasting] = useState(false); const [isWizardOpen, setIsWizardOpen] = useState(false); const [activeSchedule, setActiveSchedule] = useState<{ @@ -178,6 +185,51 @@ export default function PayrollScheduler() { if (simulationResult) resetSimulation(); }; + const validateField = (name: keyof PayrollFormData, value: string) => { + const result = payrollFormSchema.safeParse({ ...formData, [name]: value }); + if (!result.success) { + const fieldIssue = result.error.issues.find((i) => i.path[0] === name); + setFieldErrors((prev) => ({ ...prev, [name]: fieldIssue?.message ?? '' })); + } else { + setFieldErrors((prev) => ({ ...prev, [name]: undefined })); + } + }; + + const handleBlur = ( + e: React.FocusEvent + ) => { + const { name, value } = e.target; + setTouched((prev) => ({ ...prev, [name]: true })); + validateField(name as keyof PayrollFormData, value); + }; + + const isFormValid = () => { + // Pure check used only for disabling the submit button — no side effects. + return payrollFormSchema.safeParse(formData).success; + }; + + const validateFormState = (): boolean => { + const result = payrollFormSchema.safeParse(formData); + if (!result.success) { + const errors: PayrollFieldErrors = {}; + for (const issue of result.error.issues) { + const field = issue.path[0] as keyof PayrollFormData; + if (!errors[field]) { + errors[field] = issue.message; + } + } + setFieldErrors(errors); + const allTouched: PayrollTouched = {}; + for (const key of Object.keys(initialFormState) as (keyof PayrollFormData)[]) { + allTouched[key] = true; + } + setTouched(allTouched); + return false; + } + setFieldErrors(EMPTY_PAYROLL_ERRORS); + return true; + }; + useEffect(() => { if (!socket) return; @@ -204,6 +256,9 @@ export default function PayrollScheduler() { }, [socket, notifySuccess]); const handleInitialize = async () => { + if (!validateFormState()) { + return; + } if (!formData.employeeName || !formData.amount) { notifyError('Missing required fields', 'Please provide employee name and amount.'); return; @@ -413,8 +468,12 @@ export default function PayrollScheduler() { name="employeeName" value={formData.employeeName} onChange={handleChange} + onBlur={handleBlur} placeholder="e.g. Satoshi Nakamoto" /> + {touched.employeeName && fieldErrors.employeeName && ( + {fieldErrors.employeeName} + )}
@@ -425,8 +484,12 @@ export default function PayrollScheduler() { name="amount" value={formData.amount} onChange={handleChange} + onBlur={handleBlur} placeholder="0.00" /> + {touched.amount && fieldErrors.amount && ( + {fieldErrors.amount} + )}
@@ -437,12 +500,16 @@ export default function PayrollScheduler() { name="frequency" value={formData.frequency} onChange={handleChange} + onBlur={handleBlur} > {} {} + {touched.frequency && fieldErrors.frequency && ( + {fieldErrors.frequency} + )}
@@ -454,14 +521,18 @@ export default function PayrollScheduler() { type="date" value={formData.startDate} onChange={handleChange} + onBlur={handleBlur} /> + {touched.startDate && fieldErrors.startDate && ( + {fieldErrors.startDate} + )}
{!simulationPassed ? (