diff --git a/frontend/src/pages/EmployeeEntry.tsx b/frontend/src/pages/EmployeeEntry.tsx index 88b6f085..9c0f29f6 100644 --- a/frontend/src/pages/EmployeeEntry.tsx +++ b/frontend/src/pages/EmployeeEntry.tsx @@ -21,6 +21,7 @@ interface EmployeeFormState { interface EmployeeItem { id: string; + organizationId: number; name: string; email: string; imageUrl?: string; @@ -39,6 +40,7 @@ const initialFormState: EmployeeFormState = { interface BackendEmployee { id: number; + organization_id: number; first_name: string; last_name: string; email: string; @@ -48,11 +50,27 @@ interface BackendEmployee { status: string; } +interface PayrollRunRecord { + id: number; + batch_id: string; + status: 'draft' | 'pending' | 'processing' | 'completed' | 'failed'; + period_start: string; + period_end: string; + asset_code: string; + created_at: string; +} + export default function EmployeeEntry() { const [isAdding, setIsAdding] = useState(false); const [formData, setFormData] = useState(initialFormState); const [employees, setEmployees] = useState([]); const [loading, setLoading] = useState(false); + const [bonusEmployee, setBonusEmployee] = useState(null); + const [bonusRun, setBonusRun] = useState(null); + const [bonusAmount, setBonusAmount] = useState(''); + const [bonusDescription, setBonusDescription] = useState(''); + const [bonusLoading, setBonusLoading] = useState(false); + const [bonusError, setBonusError] = useState(null); const [notification, setNotification] = useState<{ message: string; secretKey?: string; @@ -60,7 +78,7 @@ export default function EmployeeEntry() { employeeName?: string; } | null>(null); - const { notifySuccess } = useNotification(); + const { notifySuccess, notifyError } = useNotification(); const { saving, lastSaved, loadSavedData } = useAutosave( 'employee-entry-draft', formData @@ -74,6 +92,7 @@ export default function EmployeeEntry() { const employeeRows = Array.isArray(response.data?.data) ? response.data.data : []; const mapped: EmployeeItem[] = employeeRows.map((emp: BackendEmployee) => ({ id: String(emp.id), + organizationId: emp.organization_id, name: `${emp.first_name} ${emp.last_name}`, email: emp.email, position: emp.position ?? emp.job_title ?? 'Employee', @@ -158,6 +177,101 @@ export default function EmployeeEntry() { } }; + const closeBonusModal = () => { + setBonusEmployee(null); + setBonusRun(null); + setBonusAmount(''); + setBonusDescription(''); + setBonusError(null); + }; + + const handleEmployeeClick = async (employee: EmployeeItem) => { + setBonusEmployee(employee); + setBonusRun(null); + setBonusAmount(''); + setBonusDescription(''); + setBonusError(null); + setBonusLoading(true); + + try { + const response = await api.get<{ + success: boolean; + data: { data: PayrollRunRecord[]; total: number }; + }>('/v1/payroll-bonus/runs', { + params: { + organizationId: employee.organizationId, + page: 1, + limit: 50, + }, + }); + + const eligibleRuns = (response.data?.data?.data ?? []) + .filter((run) => run.status === 'draft' || run.status === 'pending') + .sort((a, b) => { + const aTime = new Date(a.period_start || a.created_at).getTime(); + const bTime = new Date(b.period_start || b.created_at).getTime(); + return aTime - bTime; + }); + + const nextRun = eligibleRuns[0] ?? null; + setBonusRun(nextRun); + if (!nextRun) { + setBonusError( + "No draft or pending payroll run is available for this employee's organization." + ); + } + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to load payroll runs'; + setBonusError(message); + notifyError('Could not load the next payroll run', message); + } finally { + setBonusLoading(false); + } + }; + + const handleBonusSubmit = async (e: React.SyntheticEvent) => { + e.preventDefault(); + if (!bonusEmployee || !bonusRun) return; + + const numericAmount = Number(bonusAmount); + if (!Number.isFinite(numericAmount) || numericAmount <= 0) { + setBonusError('Enter a bonus amount greater than zero.'); + return; + } + + setBonusLoading(true); + setBonusError(null); + + try { + await api.post( + '/v1/payroll-bonus/items/bonus', + { + payrollRunId: bonusRun.id, + employeeId: Number(bonusEmployee.id), + amount: bonusAmount.trim(), + description: bonusDescription.trim() || 'Performance bonus', + }, + { + headers: { + 'Idempotency-Key': `performance-bonus-${bonusEmployee.id}-${bonusRun.id}-${Date.now()}`, + }, + } + ); + + notifySuccess( + `Performance bonus added for ${bonusEmployee.name}`, + `Included in next payroll batch ${bonusRun.batch_id}.` + ); + closeBonusModal(); + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to add performance bonus'; + setBonusError(message); + notifyError('Performance bonus was not saved', message); + } finally { + setBonusLoading(false); + } + }; + if (isAdding) { return (
console.log('Clicked:', employee.name)} - onAddEmployee={(employee: EmployeeItem) => console.log('Added:', employee)} + onEmployeeClick={(employee: EmployeeItem) => { + void handleEmployeeClick(employee); + }} + onAddEmployee={(employee: EmployeeItem) => { + void handleEmployeeClick(employee); + }} /> )} + + {bonusEmployee && ( +
{ + if (event.target === event.currentTarget && !bonusLoading) closeBonusModal(); + }} + > +
+
+
+

+ One-time payroll adjustment +

+

+ Add performance bonus +

+

{bonusEmployee.name}

+
+ +
+ + {bonusLoading && !bonusRun ? ( +

Loading the next payroll run...

+ ) : null} + + {bonusRun ? ( +
+

+ Next scheduled payment run +

+

{bonusRun.batch_id}

+
+ Status: {bonusRun.status} + + Period starts:{' '} + + {new Date(bonusRun.period_start).toLocaleDateString()} + + + Asset: {bonusRun.asset_code} +
+
+ ) : null} + +
{ + void handleBonusSubmit(event); + }} + className="space-y-4" + > + ) => + setBonusAmount(event.target.value) + } + placeholder="0.00" + disabled={!bonusRun || bonusLoading} + required + /> + +
+ +