Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 76 additions & 1 deletion frontend/src/pages/EmployeeEntry.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -36,9 +37,14 @@ const initialFormState: EmployeeFormState = {
email: '',
};

type FieldErrors = Partial<Record<keyof EmployeeFormData, string>>;
const EMPTY_ERRORS: FieldErrors = {};

export default function EmployeeEntry() {
const [isAdding, setIsAdding] = useState(false);
const [formData, setFormData] = useState<EmployeeFormState>(initialFormState);
const [fieldErrors, setFieldErrors] = useState<FieldErrors>(EMPTY_ERRORS);
const [touched, setTouched] = useState<Partial<Record<keyof EmployeeFormData, boolean>>>({});
const [employees, setEmployees] = useState<EmployeeItem[]>([]);
const [loading, setLoading] = useState(false);
const [notification, setNotification] = useState<{
Expand Down Expand Up @@ -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<HTMLInputElement | HTMLSelectElement>
) => {
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<Record<keyof EmployeeFormData, boolean>> = {};
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();
Expand Down Expand Up @@ -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 && (
<span className="text-xs text-red-500 -mt-3 block">{fieldErrors.fullName}</span>
)}
<Input
id="email"
fieldSize="md"
Expand All @@ -273,9 +334,13 @@ export default function EmployeeEntry() {
type="email"
value={formData.email}
onChange={handleChange}
onBlur={handleBlur}
placeholder="jane.smith@example.com"
required
/>
{touched.email && fieldErrors.email && (
<span className="text-xs text-red-500 -mt-3 block">{fieldErrors.email}</span>
)}
<Input
id="walletAddress"
fieldSize="md"
Expand All @@ -284,8 +349,12 @@ export default function EmployeeEntry() {
name="walletAddress"
value={formData.walletAddress}
onChange={handleChange}
onBlur={handleBlur}
placeholder="Leave blank to generate a wallet"
/>
{touched.walletAddress && fieldErrors.walletAddress && (
<span className="text-xs text-red-500 -mt-3 block">{fieldErrors.walletAddress}</span>
)}
<Select
id="role"
fieldSize="md"
Expand All @@ -299,6 +368,9 @@ export default function EmployeeEntry() {
<option value="full-time">Full Time</option>
<option value="part-time">Part Time</option>
</Select>
{touched.role && fieldErrors.role && (
<span className="text-xs text-red-500 -mt-3 block">{fieldErrors.role}</span>
)}
<Select
id="currency"
fieldSize="md"
Expand All @@ -312,7 +384,10 @@ export default function EmployeeEntry() {
<option value="XLM">XLM</option>
<option value="EURC">EURC</option>
</Select>
<Button type="submit" variant="primary" size="md">
{touched.currency && fieldErrors.currency && (
<span className="text-xs text-red-500 -mt-3 block">{fieldErrors.currency}</span>
)}
<Button type="submit" variant="primary" size="md" disabled={!isFormValid()}>
Add Employee
</Button>
</form>
Expand Down
73 changes: 72 additions & 1 deletion frontend/src/pages/PayrollScheduler.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
ScheduleRecord,
} from '../services/scheduleApi';
import { BulkPaymentStatusTracker } from '../components/BulkPaymentStatusTracker';
import { payrollFormSchema, type PayrollFormData } from '../schemas';

interface EmployeePreference {
id: string;
Expand Down Expand Up @@ -73,13 +74,19 @@ const initialFormState: PayrollFormState = {
memo: '',
};

type PayrollFieldErrors = Partial<Record<keyof PayrollFormData, string>>;
const EMPTY_PAYROLL_ERRORS: PayrollFieldErrors = {};
type PayrollTouched = Partial<Record<keyof PayrollFormData, boolean>>;

export default function PayrollScheduler() {
const { t } = useTranslation();
const { notifySuccess, notifyError } = useNotification();
const socketContext = useSocket();

const { socket, subscribeToTransaction, unsubscribeFromTransaction } = socketContext;
const [formData, setFormData] = useState<PayrollFormState>(initialFormState);
const [fieldErrors, setFieldErrors] = useState<PayrollFieldErrors>(EMPTY_PAYROLL_ERRORS);
const [touched, setTouched] = useState<PayrollTouched>({});
const [isBroadcasting, setIsBroadcasting] = useState(false);
const [isWizardOpen, setIsWizardOpen] = useState(false);
const [activeSchedule, setActiveSchedule] = useState<{
Expand Down Expand Up @@ -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<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>
) => {
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;

Expand All @@ -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;
Expand Down Expand Up @@ -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 && (
<span className="text-xs text-red-500 block">{fieldErrors.employeeName}</span>
)}
</div>

<div>
Expand All @@ -425,8 +484,12 @@ export default function PayrollScheduler() {
name="amount"
value={formData.amount}
onChange={handleChange}
onBlur={handleBlur}
placeholder="0.00"
/>
{touched.amount && fieldErrors.amount && (
<span className="text-xs text-red-500 block">{fieldErrors.amount}</span>
)}
</div>

<div>
Expand All @@ -437,12 +500,16 @@ export default function PayrollScheduler() {
name="frequency"
value={formData.frequency}
onChange={handleChange}
onBlur={handleBlur}
>
{}
<option value="weekly">{t('payroll.frequencyWeekly', 'Weekly')}</option>
{}
<option value="monthly">{t('payroll.frequencyMonthly', 'Monthly')}</option>
</Select>
{touched.frequency && fieldErrors.frequency && (
<span className="text-xs text-red-500 block">{fieldErrors.frequency}</span>
)}
</div>

<div className="md:col-span-2">
Expand All @@ -454,14 +521,18 @@ export default function PayrollScheduler() {
type="date"
value={formData.startDate}
onChange={handleChange}
onBlur={handleBlur}
/>
{touched.startDate && fieldErrors.startDate && (
<span className="text-xs text-red-500 block">{fieldErrors.startDate}</span>
)}
</div>

<div className="md:col-span-2 pt-4">
{!simulationPassed ? (
<Button
type="submit"
disabled={isSimulating}
disabled={isSimulating || !isFormValid()}
variant="primary"
size="md"
isFullWidth
Expand Down
54 changes: 54 additions & 0 deletions frontend/src/schemas.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { z } from 'zod';

export const employeeFormSchema = z.object({
fullName: z
.string()
.min(1, 'Full name is required')
.min(2, 'Full name must be at least 2 characters'),
email: z
.string()
.min(1, 'Email address is required')
.email('Please enter a valid email address'),
walletAddress: z
.string()
.optional()
.refine(
(val) => !val || /^G[A-Z2-7]{55}$/.test(val),
'Wallet address must be a valid Stellar public key (G...55 chars)'
),
role: z.enum(['contractor', 'full-time', 'part-time'], {
errorMap: () => ({ message: 'Please select a valid role' }),
}),
currency: z.enum(['USDC', 'XLM', 'EURC'], {
errorMap: () => ({ message: 'Please select a valid currency' }),
}),
});

export type EmployeeFormData = z.infer<typeof employeeFormSchema>;

export const payrollFormSchema = z.object({
employeeName: z
.string()
.min(1, 'Employee name is required')
.min(2, 'Employee name must be at least 2 characters'),
amount: z
.string()
.min(1, 'Amount is required')
.refine(
(val) => !isNaN(Number(val)) && Number(val) > 0,
'Amount must be a positive number'
),
frequency: z.enum(['weekly', 'monthly'], {
errorMap: () => ({ message: 'Please select a frequency' }),
}),
startDate: z
.string()
.min(1, 'Start date is required')
.refine(
(val) => !isNaN(Date.parse(val)),
'Please enter a valid date'
),
memo: z.string().optional(),
});

export type PayrollFormData = z.infer<typeof payrollFormSchema>;