From 355c2bee1b6a1418b0ac364e3a87d39ebaa8eaa7 Mon Sep 17 00:00:00 2001 From: Aaron Lee Date: Mon, 29 Dec 2025 16:12:42 -0500 Subject: [PATCH 01/12] feat: add core terminations functionality --- .../EmployeeTerminations.test.tsx | 202 ++++++++++++++++++ .../Terminations/EmployeeTerminations.tsx | 120 +++++++++++ .../EmployeeTerminationsPresentation.tsx | 94 ++++++++ src/components/Terminations/index.tsx | 1 + src/components/index.ts | 1 + .../en/Terminations.EmployeeTerminations.json | 35 +++ src/shared/constants.ts | 6 + src/types/i18next.d.ts | 37 +++- 8 files changed, 495 insertions(+), 1 deletion(-) create mode 100644 src/components/Terminations/EmployeeTerminations.test.tsx create mode 100644 src/components/Terminations/EmployeeTerminations.tsx create mode 100644 src/components/Terminations/EmployeeTerminationsPresentation.tsx create mode 100644 src/components/Terminations/index.tsx create mode 100644 src/i18n/en/Terminations.EmployeeTerminations.json diff --git a/src/components/Terminations/EmployeeTerminations.test.tsx b/src/components/Terminations/EmployeeTerminations.test.tsx new file mode 100644 index 00000000..58cec9d4 --- /dev/null +++ b/src/components/Terminations/EmployeeTerminations.test.tsx @@ -0,0 +1,202 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { http, HttpResponse } from 'msw' +import { EmployeeTerminations } from './EmployeeTerminations' +import { server } from '@/test/mocks/server' +import { componentEvents } from '@/shared/constants' +import { setupApiTestMocks } from '@/test/mocks/apiServer' +import { renderWithProviders } from '@/test-utils/renderWithProviders' +import { API_BASE_URL } from '@/test/constants' + +const mockEmployee = { + uuid: 'employee-123', + first_name: 'John', + last_name: 'Doe', + email: 'john.doe@example.com', + company_uuid: 'company-123', + terminated: false, + onboarded: true, +} + +const mockTermination = { + uuid: 'termination-123', + employee_uuid: 'employee-123', + effective_date: '2025-01-15', + run_termination_payroll: true, + active: false, + cancelable: true, +} + +describe('EmployeeTerminations', () => { + const onEvent = vi.fn() + const user = userEvent.setup() + const defaultProps = { + employeeId: 'employee-123', + onEvent, + } + + beforeEach(() => { + setupApiTestMocks() + onEvent.mockClear() + + server.use( + http.get(`${API_BASE_URL}/v1/employees/:employee_id`, () => { + return HttpResponse.json(mockEmployee) + }), + http.post(`${API_BASE_URL}/v1/employees/:employee_id/terminations`, () => { + return HttpResponse.json(mockTermination) + }), + ) + }) + + describe('rendering', () => { + it('renders the termination form with employee name', async () => { + renderWithProviders() + + await waitFor(() => { + expect(screen.getByRole('heading', { name: 'Terminate John Doe' })).toBeInTheDocument() + }) + + expect( + screen.getByText(/Set their last day of work and choose how to handle their final payroll/), + ).toBeInTheDocument() + }) + + it('renders the date picker for last day of work', async () => { + renderWithProviders() + + await waitFor(() => { + expect(screen.getByText('Last day of work')).toBeInTheDocument() + }) + }) + + it('renders all payroll options', async () => { + renderWithProviders() + + await waitFor(() => { + expect(screen.getByText('Dismissal payroll')).toBeInTheDocument() + }) + + expect(screen.getByText('Regular payroll')).toBeInTheDocument() + expect(screen.getByText('Another way')).toBeInTheDocument() + }) + + it('renders submit and cancel buttons', async () => { + renderWithProviders() + + await waitFor(() => { + expect(screen.getByRole('button', { name: 'Terminate employee' })).toBeInTheDocument() + }) + + expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument() + }) + }) + + describe('form validation', () => { + it('shows validation error when submitting without date', async () => { + renderWithProviders() + + await waitFor(() => { + expect(screen.getByRole('button', { name: 'Terminate employee' })).toBeInTheDocument() + }) + + await user.click(screen.getByRole('button', { name: 'Terminate employee' })) + + await waitFor(() => { + expect(screen.getByText('Last day of work is required')).toBeInTheDocument() + }) + }) + + it('shows validation error when submitting without payroll option', async () => { + renderWithProviders() + + await waitFor(() => { + expect(screen.getByRole('button', { name: 'Terminate employee' })).toBeInTheDocument() + }) + + await user.click(screen.getByRole('button', { name: 'Terminate employee' })) + + await waitFor(() => { + expect( + screen.getByText('Please select how to handle the final payroll'), + ).toBeInTheDocument() + }) + }) + }) + + describe('cancel action', () => { + it('emits CANCEL event when cancel button is clicked', async () => { + renderWithProviders() + + await waitFor(() => { + expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument() + }) + + await user.click(screen.getByRole('button', { name: 'Cancel' })) + + expect(onEvent).toHaveBeenCalledWith(componentEvents.CANCEL) + }) + }) + + describe('payroll option selection', () => { + it('allows selecting dismissal payroll option', async () => { + renderWithProviders() + + await waitFor(() => { + expect(screen.getByText('Dismissal payroll')).toBeInTheDocument() + }) + + const dismissalRadio = screen.getByLabelText('Dismissal payroll') + await user.click(dismissalRadio) + + expect(dismissalRadio).toBeChecked() + }) + + it('allows selecting regular payroll option', async () => { + renderWithProviders() + + await waitFor(() => { + expect(screen.getByText('Regular payroll')).toBeInTheDocument() + }) + + const regularRadio = screen.getByLabelText('Regular payroll') + await user.click(regularRadio) + + expect(regularRadio).toBeChecked() + }) + + it('allows selecting another way option', async () => { + renderWithProviders() + + await waitFor(() => { + expect(screen.getByText('Another way')).toBeInTheDocument() + }) + + const anotherWayRadio = screen.getByLabelText('Another way') + await user.click(anotherWayRadio) + + expect(anotherWayRadio).toBeChecked() + }) + + it('shows option descriptions', async () => { + renderWithProviders() + + await waitFor(() => { + expect( + screen.getByText( + /Run an off-cycle payroll to pay the employee their final wages immediately/, + ), + ).toBeInTheDocument() + }) + + expect( + screen.getByText( + /The employee will receive their final wages on their current pay schedule/, + ), + ).toBeInTheDocument() + + expect(screen.getByText(/Handle the final payment manually/)).toBeInTheDocument() + }) + }) +}) diff --git a/src/components/Terminations/EmployeeTerminations.tsx b/src/components/Terminations/EmployeeTerminations.tsx new file mode 100644 index 00000000..92a1d4ea --- /dev/null +++ b/src/components/Terminations/EmployeeTerminations.tsx @@ -0,0 +1,120 @@ +import { useState } from 'react' +import { useEmployeesGetSuspense } from '@gusto/embedded-api/react-query/employeesGet' +import { useEmployeeEmploymentsCreateTerminationMutation } from '@gusto/embedded-api/react-query/employeeEmploymentsCreateTermination' +import { + EmployeeTerminationsPresentation, + type PayrollOption, +} from './EmployeeTerminationsPresentation' +import type { BaseComponentInterface } from '@/components/Base/Base' +import { BaseComponent } from '@/components/Base/Base' +import { useBase } from '@/components/Base/useBase' +import { componentEvents } from '@/shared/constants' +import { useComponentDictionary, useI18n } from '@/i18n' + +export interface EmployeeTerminationsProps extends BaseComponentInterface<'Terminations.EmployeeTerminations'> { + employeeId: string +} + +export function EmployeeTerminations(props: EmployeeTerminationsProps) { + return ( + + {props.children} + + ) +} + +const Root = ({ employeeId, dictionary }: EmployeeTerminationsProps) => { + useComponentDictionary('Terminations.EmployeeTerminations', dictionary) + useI18n('Terminations.EmployeeTerminations') + + const { onEvent, baseSubmitHandler } = useBase() + + const [lastDayOfWork, setLastDayOfWork] = useState(null) + const [payrollOption, setPayrollOption] = useState(null) + const [lastDayError, setLastDayError] = useState() + const [payrollOptionError, setPayrollOptionError] = useState() + + const { + data: { employee }, + } = useEmployeesGetSuspense({ employeeId }) + + const { mutateAsync: createTermination, isPending } = + useEmployeeEmploymentsCreateTerminationMutation() + + const employeeName = [employee?.firstName, employee?.lastName].filter(Boolean).join(' ') + + const validateForm = (): boolean => { + let isValid = true + + if (!lastDayOfWork) { + setLastDayError('Last day of work is required') + isValid = false + } else { + setLastDayError(undefined) + } + + if (!payrollOption) { + setPayrollOptionError('Please select how to handle the final payroll') + isValid = false + } else { + setPayrollOptionError(undefined) + } + + return isValid + } + + const handleSubmit = async () => { + if (!validateForm()) { + return + } + + const effectiveDate = lastDayOfWork!.toISOString().split('T')[0]! + + await baseSubmitHandler({ effectiveDate, payrollOption }, async () => { + const runTerminationPayroll = payrollOption === 'dismissalPayroll' + + const result = await createTermination({ + request: { + employeeId, + requestBody: { + effectiveDate, + runTerminationPayroll, + }, + }, + }) + + onEvent(componentEvents.EMPLOYEE_TERMINATION_CREATED, { + termination: result.termination, + payrollOption, + runTerminationPayroll, + }) + + onEvent(componentEvents.EMPLOYEE_TERMINATION_DONE, { + employeeId, + effectiveDate, + payrollOption, + termination: result.termination, + ...(payrollOption === 'anotherWay' && { manualHandling: true }), + }) + }) + } + + const handleCancel = () => { + onEvent(componentEvents.CANCEL) + } + + return ( + + ) +} diff --git a/src/components/Terminations/EmployeeTerminationsPresentation.tsx b/src/components/Terminations/EmployeeTerminationsPresentation.tsx new file mode 100644 index 00000000..d713717a --- /dev/null +++ b/src/components/Terminations/EmployeeTerminationsPresentation.tsx @@ -0,0 +1,94 @@ +import { useTranslation } from 'react-i18next' +import { Flex, ActionsLayout } from '@/components/Common' +import { useComponentContext } from '@/contexts/ComponentAdapter/useComponentContext' + +export type PayrollOption = 'dismissalPayroll' | 'regularPayroll' | 'anotherWay' + +interface EmployeeTerminationsPresentationProps { + employeeName: string + lastDayOfWork: Date | null + onLastDayOfWorkChange: (date: Date | null) => void + payrollOption: PayrollOption | null + onPayrollOptionChange: (option: PayrollOption) => void + onSubmit: () => void + onCancel: () => void + isLoading: boolean + lastDayError?: string + payrollOptionError?: string +} + +export function EmployeeTerminationsPresentation({ + employeeName, + lastDayOfWork, + onLastDayOfWorkChange, + payrollOption, + onPayrollOptionChange, + onSubmit, + onCancel, + isLoading, + lastDayError, + payrollOptionError, +}: EmployeeTerminationsPresentationProps) { + const { Heading, Text, DatePicker, RadioGroup, Button } = useComponentContext() + const { t } = useTranslation('Terminations.EmployeeTerminations') + + const payrollOptions = [ + { + value: 'dismissalPayroll' as const, + label: t('form.payrollOption.options.dismissalPayroll.label'), + description: t('form.payrollOption.options.dismissalPayroll.description'), + }, + { + value: 'regularPayroll' as const, + label: t('form.payrollOption.options.regularPayroll.label'), + description: t('form.payrollOption.options.regularPayroll.description'), + }, + { + value: 'anotherWay' as const, + label: t('form.payrollOption.options.anotherWay.label'), + description: t('form.payrollOption.options.anotherWay.description'), + }, + ] + + return ( + + + {t('title', { employeeName })} + {t('subtitle')} + + + + + + { + onPayrollOptionChange(value as PayrollOption) + }} + isRequired + errorMessage={payrollOptionError} + isInvalid={!!payrollOptionError} + options={payrollOptions} + /> + + + + + + + + ) +} diff --git a/src/components/Terminations/index.tsx b/src/components/Terminations/index.tsx new file mode 100644 index 00000000..7507753e --- /dev/null +++ b/src/components/Terminations/index.tsx @@ -0,0 +1 @@ +export { EmployeeTerminations } from './EmployeeTerminations' diff --git a/src/components/index.ts b/src/components/index.ts index 65177cfb..dc942db6 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -3,3 +3,4 @@ export * as Company from './Company' export * as Contractor from './Contractor' export * as Employee from './Employee' export * as Payroll from './Payroll' +export * as Terminations from './Terminations' diff --git a/src/i18n/en/Terminations.EmployeeTerminations.json b/src/i18n/en/Terminations.EmployeeTerminations.json new file mode 100644 index 00000000..63c17401 --- /dev/null +++ b/src/i18n/en/Terminations.EmployeeTerminations.json @@ -0,0 +1,35 @@ +{ + "title": "Terminate {{employeeName}}", + "subtitle": "Set their last day of work and choose how to handle their final payroll.", + "form": { + "lastDayOfWork": { + "label": "Last day of work", + "description": "The employee's final day working at your company." + }, + "payrollOption": { + "label": "How should the final payroll be handled?", + "options": { + "dismissalPayroll": { + "label": "Dismissal payroll", + "description": "Run an off-cycle payroll to pay the employee their final wages immediately. This is required in some states." + }, + "regularPayroll": { + "label": "Regular payroll", + "description": "The employee will receive their final wages on their current pay schedule." + }, + "anotherWay": { + "label": "Another way", + "description": "Handle the final payment manually (e.g., paper check). No payroll will be created." + } + } + } + }, + "actions": { + "submit": "Terminate employee", + "cancel": "Cancel" + }, + "validation": { + "lastDayRequired": "Last day of work is required", + "payrollOptionRequired": "Please select how to handle the final payroll" + } +} diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 6f59167d..116d2a63 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -121,6 +121,11 @@ export const contractorPaymentEvents = { CONTRACTOR_PAYMENT_VIEW: 'contractor/payments/view', } as const +export const terminationEvents = { + EMPLOYEE_TERMINATION_CREATED: 'employee/termination/created', + EMPLOYEE_TERMINATION_DONE: 'employee/termination/done', +} as const + export const payScheduleEvents = { PAY_SCHEDULE_CREATE: 'paySchedule/create', PAY_SCHEDULE_CREATED: 'paySchedule/created', @@ -177,6 +182,7 @@ export const componentEvents = { ...runPayrollEvents, ...payrollWireEvents, ...contractorPaymentEvents, + ...terminationEvents, } as const export type EventType = (typeof componentEvents)[keyof typeof componentEvents] diff --git a/src/types/i18next.d.ts b/src/types/i18next.d.ts index 8ac01ea7..7bee0cf0 100644 --- a/src/types/i18next.d.ts +++ b/src/types/i18next.d.ts @@ -1681,6 +1681,41 @@ export interface PayrollWireInstructions{ "confirm":string; }; }; +export interface TerminationsEmployeeTerminations{ +"title":string; +"subtitle":string; +"form":{ +"lastDayOfWork":{ +"label":string; +"description":string; +}; +"payrollOption":{ +"label":string; +"options":{ +"dismissalPayroll":{ +"label":string; +"description":string; +}; +"regularPayroll":{ +"label":string; +"description":string; +}; +"anotherWay":{ +"label":string; +"description":string; +}; +}; +}; +}; +"actions":{ +"submit":string; +"cancel":string; +}; +"validation":{ +"lastDayRequired":string; +"payrollOptionRequired":string; +}; +}; export interface common{ "status":{ "loading":string; @@ -1855,6 +1890,6 @@ export interface common{ interface CustomTypeOptions { defaultNS: 'common'; - resources: { 'Company.Addresses': CompanyAddresses, 'Company.AssignSignatory': CompanyAssignSignatory, 'Company.BankAccount': CompanyBankAccount, 'Company.DocumentList': CompanyDocumentList, 'Company.FederalTaxes': CompanyFederalTaxes, 'Company.Industry': CompanyIndustry, 'Company.Locations': CompanyLocations, 'Company.OnboardingOverview': CompanyOnboardingOverview, 'Company.PaySchedule': CompanyPaySchedule, 'Company.SignatureForm': CompanySignatureForm, 'Company.StateTaxes': CompanyStateTaxes, 'Contractor.Address': ContractorAddress, 'Contractor.ContractorList': ContractorContractorList, 'Contractor.NewHireReport': ContractorNewHireReport, 'Contractor.PaymentMethod': ContractorPaymentMethod, 'Contractor.Payments.CreatePayment': ContractorPaymentsCreatePayment, 'Contractor.Payments.PaymentHistory': ContractorPaymentsPaymentHistory, 'Contractor.Payments.PaymentsList': ContractorPaymentsPaymentsList, 'Contractor.Profile': ContractorProfile, 'Contractor.Submit': ContractorSubmit, 'Employee.BankAccount': EmployeeBankAccount, 'Employee.Compensation': EmployeeCompensation, 'Employee.Deductions': EmployeeDeductions, 'Employee.DocumentSigner': EmployeeDocumentSigner, 'Employee.EmployeeList': EmployeeEmployeeList, 'Employee.FederalTaxes': EmployeeFederalTaxes, 'Employee.HomeAddress': EmployeeHomeAddress, 'Employee.Landing': EmployeeLanding, 'Employee.OnboardingSummary': EmployeeOnboardingSummary, 'Employee.PaySchedules': EmployeePaySchedules, 'Employee.PaymentMethod': EmployeePaymentMethod, 'Employee.Profile': EmployeeProfile, 'Employee.SplitPaycheck': EmployeeSplitPaycheck, 'Employee.StateTaxes': EmployeeStateTaxes, 'Employee.Taxes': EmployeeTaxes, 'Payroll.Common': PayrollCommon, 'Payroll.ConfirmWireDetailsBanner': PayrollConfirmWireDetailsBanner, 'Payroll.ConfirmWireDetailsForm': PayrollConfirmWireDetailsForm, 'Payroll.PayrollBlocker': PayrollPayrollBlocker, 'Payroll.PayrollConfiguration': PayrollPayrollConfiguration, 'Payroll.PayrollEditEmployee': PayrollPayrollEditEmployee, 'Payroll.PayrollFlow': PayrollPayrollFlow, 'Payroll.PayrollHistory': PayrollPayrollHistory, 'Payroll.PayrollLanding': PayrollPayrollLanding, 'Payroll.PayrollList': PayrollPayrollList, 'Payroll.PayrollOverview': PayrollPayrollOverview, 'Payroll.PayrollReceipts': PayrollPayrollReceipts, 'Payroll.WireInstructions': PayrollWireInstructions, 'common': common, } + resources: { 'Company.Addresses': CompanyAddresses, 'Company.AssignSignatory': CompanyAssignSignatory, 'Company.BankAccount': CompanyBankAccount, 'Company.DocumentList': CompanyDocumentList, 'Company.FederalTaxes': CompanyFederalTaxes, 'Company.Industry': CompanyIndustry, 'Company.Locations': CompanyLocations, 'Company.OnboardingOverview': CompanyOnboardingOverview, 'Company.PaySchedule': CompanyPaySchedule, 'Company.SignatureForm': CompanySignatureForm, 'Company.StateTaxes': CompanyStateTaxes, 'Contractor.Address': ContractorAddress, 'Contractor.ContractorList': ContractorContractorList, 'Contractor.NewHireReport': ContractorNewHireReport, 'Contractor.PaymentMethod': ContractorPaymentMethod, 'Contractor.Payments.CreatePayment': ContractorPaymentsCreatePayment, 'Contractor.Payments.PaymentHistory': ContractorPaymentsPaymentHistory, 'Contractor.Payments.PaymentsList': ContractorPaymentsPaymentsList, 'Contractor.Profile': ContractorProfile, 'Contractor.Submit': ContractorSubmit, 'Employee.BankAccount': EmployeeBankAccount, 'Employee.Compensation': EmployeeCompensation, 'Employee.Deductions': EmployeeDeductions, 'Employee.DocumentSigner': EmployeeDocumentSigner, 'Employee.EmployeeList': EmployeeEmployeeList, 'Employee.FederalTaxes': EmployeeFederalTaxes, 'Employee.HomeAddress': EmployeeHomeAddress, 'Employee.Landing': EmployeeLanding, 'Employee.OnboardingSummary': EmployeeOnboardingSummary, 'Employee.PaySchedules': EmployeePaySchedules, 'Employee.PaymentMethod': EmployeePaymentMethod, 'Employee.Profile': EmployeeProfile, 'Employee.SplitPaycheck': EmployeeSplitPaycheck, 'Employee.StateTaxes': EmployeeStateTaxes, 'Employee.Taxes': EmployeeTaxes, 'Payroll.Common': PayrollCommon, 'Payroll.ConfirmWireDetailsBanner': PayrollConfirmWireDetailsBanner, 'Payroll.ConfirmWireDetailsForm': PayrollConfirmWireDetailsForm, 'Payroll.PayrollBlocker': PayrollPayrollBlocker, 'Payroll.PayrollConfiguration': PayrollPayrollConfiguration, 'Payroll.PayrollEditEmployee': PayrollPayrollEditEmployee, 'Payroll.PayrollFlow': PayrollPayrollFlow, 'Payroll.PayrollHistory': PayrollPayrollHistory, 'Payroll.PayrollLanding': PayrollPayrollLanding, 'Payroll.PayrollList': PayrollPayrollList, 'Payroll.PayrollOverview': PayrollPayrollOverview, 'Payroll.PayrollReceipts': PayrollPayrollReceipts, 'Payroll.WireInstructions': PayrollWireInstructions, 'Terminations.EmployeeTerminations': TerminationsEmployeeTerminations, 'common': common, } }; } \ No newline at end of file From 1715fbdce773b66241568817dd52e796c56ca644 Mon Sep 17 00:00:00 2001 From: Aaron Lee Date: Mon, 29 Dec 2025 16:31:27 -0500 Subject: [PATCH 02/12] fix: add i18n hook call --- .../Terminations/EmployeeTerminationsPresentation.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/components/Terminations/EmployeeTerminationsPresentation.tsx b/src/components/Terminations/EmployeeTerminationsPresentation.tsx index d713717a..dfe0cc40 100644 --- a/src/components/Terminations/EmployeeTerminationsPresentation.tsx +++ b/src/components/Terminations/EmployeeTerminationsPresentation.tsx @@ -1,6 +1,7 @@ import { useTranslation } from 'react-i18next' import { Flex, ActionsLayout } from '@/components/Common' import { useComponentContext } from '@/contexts/ComponentAdapter/useComponentContext' +import { useI18n } from '@/i18n' export type PayrollOption = 'dismissalPayroll' | 'regularPayroll' | 'anotherWay' @@ -30,6 +31,7 @@ export function EmployeeTerminationsPresentation({ payrollOptionError, }: EmployeeTerminationsPresentationProps) { const { Heading, Text, DatePicker, RadioGroup, Button } = useComponentContext() + useI18n('Terminations.EmployeeTerminations') const { t } = useTranslation('Terminations.EmployeeTerminations') const payrollOptions = [ From a67461ff2293d07f01b6fcf67d47ea50057f9380 Mon Sep 17 00:00:00 2001 From: Aaron Lee Date: Tue, 30 Dec 2025 14:17:43 -0500 Subject: [PATCH 03/12] feat: add playground for simulating termiantions, create off-cycle dismissal --- package-lock.json | 378 ++++++++- package.json | 4 +- patches/@gusto+embedded-api+0.11.4.patch | 26 + .../EmployeeTerminations.test.tsx | 28 + .../Terminations/EmployeeTerminations.tsx | 66 +- .../Terminations/TerminationsData.tsx | 768 ++++++++++++++++++ src/components/Terminations/index.tsx | 1 + src/shared/constants.ts | 2 + 8 files changed, 1226 insertions(+), 47 deletions(-) create mode 100644 patches/@gusto+embedded-api+0.11.4.patch create mode 100644 src/components/Terminations/TerminationsData.tsx diff --git a/package-lock.json b/package-lock.json index c9c99997..b9ed707f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,6 +7,7 @@ "": { "name": "@gusto/embedded-react-sdk", "version": "0.24.1", + "hasInstallScript": true, "license": "MIT", "dependencies": { "@gusto/embedded-api": "^0.11.4", @@ -58,6 +59,7 @@ "lint-staged": "^16.2.7", "msw": "^2.12.7", "npm-run-all": "^4.1.5", + "patch-package": "^8.0.1", "prettier": "^3.7.4", "react-dnd": "^16.0.1", "react-dnd-html5-backend": "^16.0.1", @@ -154,7 +156,6 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -458,6 +459,7 @@ "integrity": "sha512-7e8SScMocHxcAb8YhtkbMhGG+EKLRIficb1F5sjvhSYsWTZGxvg4KIDp8kgxnV2PUJ3ddPe6J9QESjKvBWRDkg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@cacheable/utils": "^2.3.2", "@keyv/bigmap": "^1.3.0", @@ -471,6 +473,7 @@ "integrity": "sha512-KT01GjzV6AQD5+IYrcpoYLkCu1Jod3nau1Z7EsEuViO3TZGRacSbO9MfHmbJ1WaOXFtWLxPVj169cn2WNKPkIg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "hashery": "^1.2.0", "hookified": "^1.13.0" @@ -499,6 +502,7 @@ "integrity": "sha512-8kGE2P+HjfY8FglaOiW+y8qxcaQAfAhVML+i66XJR3YX5FtyDqn6Txctr3K2FrbxLKixRRYYBWMbuGciOhYNDg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "hashery": "^1.2.0", "keyv": "^5.5.4" @@ -510,6 +514,7 @@ "integrity": "sha512-FA5LmZVF1VziNc0bIdCSA1IoSVnDCqE8HJIZZv2/W8YmoAM50+tnUgJR/gQZwEeIMleuIOnRnHA/UaZRNeV4iQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@keyv/serialize": "^1.1.1" } @@ -859,7 +864,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" }, @@ -883,6 +887,7 @@ } ], "license": "MIT-0", + "peer": true, "engines": { "node": ">=18" } @@ -903,7 +908,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" } @@ -924,6 +928,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" }, @@ -948,6 +953,7 @@ } ], "license": "MIT-0", + "peer": true, "engines": { "node": ">=18" }, @@ -961,6 +967,7 @@ "integrity": "sha512-id+7YRUgoUX6CgV0DtuhirQWodeeA7Lf4i2x71JS/vtA5pRb/hIGWlw+G6MeXvsM+MXrz0VAydTGElX1rAfgPg==", "dev": true, "license": "MIT", + "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/JounQin" @@ -2247,7 +2254,8 @@ "resolved": "https://registry.npmjs.org/@keyv/serialize/-/serialize-1.1.1.tgz", "integrity": "sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@ladle/react": { "version": "5.1.1", @@ -5820,6 +5828,7 @@ "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.12.tgz", "integrity": "sha512-T1/8t5DhV/SisWjDnaiU2drl6ySvsHj1bHBCWNXd+/T+Hh1cf6JodyEYMd5sgwm+b/mETT4EV3H+zCVczCU5hg==", "license": "MIT", + "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/tannerlinsley" @@ -5972,7 +5981,8 @@ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@types/babel__core": { "version": "7.20.5", @@ -6229,7 +6239,6 @@ "integrity": "sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -6247,7 +6256,6 @@ "integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -6342,7 +6350,6 @@ "integrity": "sha512-3xP4XzzDNQOIqBMWogftkwxhg5oMKApqY0BAflmLZiFYHqyhSOxv/cd/zPQLTcCXr4AkaKb25joocY0BD1WC6A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.51.0", "@typescript-eslint/types": "8.51.0", @@ -6888,6 +6895,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@yarnpkg/lockfile": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@yarnpkg/lockfile/-/lockfile-1.1.0.tgz", + "integrity": "sha512-GpSwvyXOcOOlV70vbnzjj4fW5xW/FdUF6nQEt1ENy7m4ZCczi1+/buVUPAqmGfqznsORNFzUMjctTIp8a9tuCQ==", + "dev": true, + "license": "BSD-2-Clause" + }, "node_modules/accepts": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", @@ -6908,7 +6922,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -7157,6 +7170,7 @@ "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=8" } @@ -7323,6 +7337,7 @@ "integrity": "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=8" } @@ -7503,7 +7518,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -7571,6 +7585,7 @@ "integrity": "sha512-yr+FSHWn1ZUou5LkULX/S+jhfgfnLbuKQjE40tyEd4fxGZVMbBL5ifno0J0OauykS8UiCSgHi+DV/YD+rjFxFg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@cacheable/memory": "^2.0.6", "@cacheable/utils": "^2.3.2", @@ -7585,6 +7600,7 @@ "integrity": "sha512-FA5LmZVF1VziNc0bIdCSA1IoSVnDCqE8HJIZZv2/W8YmoAM50+tnUgJR/gQZwEeIMleuIOnRnHA/UaZRNeV4iQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@keyv/serialize": "^1.1.1" } @@ -8069,7 +8085,8 @@ "resolved": "https://registry.npmjs.org/colord/-/colord-2.9.3.tgz", "integrity": "sha512-jeC1axXpnb0/2nn/Y1LPuLdgXBLH7aDcHu4KEKfqw3CUhX7ZpfBSlPKyqXE6btIgEzfWtrX3/tyBCaCvXvMkOw==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/colorette": { "version": "2.0.20", @@ -8260,7 +8277,6 @@ "integrity": "sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "env-paths": "^2.2.1", "import-fresh": "^3.3.0", @@ -8321,6 +8337,7 @@ "integrity": "sha512-IQOkD3hbR5KrN93MtcYuad6YPuTSUhntLHDuLEbFWE+ff2/XSZNdZG+LcbbIW5AXKg/WFIfYItIzVoHngHXZzA==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12 || >=16" } @@ -8355,6 +8372,7 @@ "integrity": "sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "mdn-data": "2.12.2", "source-map-js": "^1.0.1" @@ -8376,6 +8394,7 @@ "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "cssesc": "bin/cssesc" }, @@ -8751,6 +8770,7 @@ "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "path-type": "^4.0.0" }, @@ -8764,6 +8784,7 @@ "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=8" } @@ -8812,7 +8833,8 @@ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/dompurify": { "version": "3.3.1", @@ -9255,7 +9277,6 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -10024,6 +10045,7 @@ "integrity": "sha512-eRnCtTTtGZFpQCwhJiUOuxPQWRXVKYDn0b2PeHfXL6/Zi53SLAzAHfVhVWK2AryC/WH05kGfxhFIPvTF0SXQzg==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">= 4.9.1" } @@ -10113,6 +10135,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/find-yarn-workspace-root": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/find-yarn-workspace-root/-/find-yarn-workspace-root-2.0.0.tgz", + "integrity": "sha512-1IMnbjt4KzsQfnhnzNd8wUEgXZ44IzZaZmnLYx7D5FZlaHt2gW20Cri8Q+E/t5tIj4+epTBub+2Zxu/vNILzqQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "micromatch": "^4.0.2" + } + }, "node_modules/flat-cache": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", @@ -10487,6 +10519,7 @@ "integrity": "sha512-NGbfmJBp9x8IxyJSd1P+otYK8vonoJactOogrVfFRIAEY1ukil8RSKDz2Yo7wh1oihl51l/r6W4epkeKJHqL8A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "global-prefix": "^3.0.0" }, @@ -10500,6 +10533,7 @@ "integrity": "sha512-awConJSVCHVGND6x3tmMaKcQvwXLhjdkmomy2W+Goaui8YPgYgXJZewhg3fWC+DlfqqQuWg8AwqjGTD2nAPVWg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "ini": "^1.3.5", "kind-of": "^6.0.2", @@ -10514,7 +10548,8 @@ "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", "dev": true, - "license": "ISC" + "license": "ISC", + "peer": true }, "node_modules/global-prefix/node_modules/which": { "version": "1.3.1", @@ -10522,6 +10557,7 @@ "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", "dev": true, "license": "ISC", + "peer": true, "dependencies": { "isexe": "^2.0.0" }, @@ -10608,7 +10644,8 @@ "resolved": "https://registry.npmjs.org/globjoin/-/globjoin-0.1.4.tgz", "integrity": "sha512-xYfnw62CKG8nLkZBfWbhWwDw02CHty86jfPcc2cr3ZfeuK9ysoVPPEUxf21bAD/rWAgk52SuBrLJlefNy8mvFg==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/globrex": { "version": "0.1.2", @@ -10734,6 +10771,7 @@ "integrity": "sha512-fWltioiy5zsSAs9ouEnvhsVJeAXRybGCNNv0lvzpzNOSDbULXRy7ivFWwCCv4I5Am6kSo75hmbsCduOoc2/K4w==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "hookified": "^1.13.0" }, @@ -11044,7 +11082,8 @@ "resolved": "https://registry.npmjs.org/hookified/-/hookified-1.14.0.tgz", "integrity": "sha512-pi1ynXIMFx/uIIwpWJ/5CEtOHLGtnUB0WhGeeYT+fKcQ+WCQbm3/rrkAXnpfph++PgepNqPdTC2WTj8A6k6zoQ==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/hosted-git-info": { "version": "2.8.9", @@ -11088,6 +11127,7 @@ "integrity": "sha512-ztqyC3kLto0e9WbNp0aeP+M3kTt+nbaIveGmUxAtZa+8iFgKLUOD4YKM5j+f3QD89bra7UeumolZHKuOXnTmeQ==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=8" }, @@ -11210,7 +11250,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.28.4" }, @@ -11761,6 +11800,7 @@ "integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -12690,7 +12730,6 @@ "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "cssstyle": "^4.2.1", "data-urls": "^5.0.0", @@ -12780,6 +12819,26 @@ "dev": true, "license": "MIT" }, + "node_modules/json-stable-stringify": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/json-stable-stringify/-/json-stable-stringify-1.3.0.tgz", + "integrity": "sha512-qtYiSSFlwot9XHtF9bD9c7rwKjr+RecWT//ZnPvSmEjpV5mmPOCN4j8UjY5hbjNkOwZ/jQv3J6R1/pL7RwgMsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "isarray": "^2.0.5", + "jsonify": "^0.0.1", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/json-stable-stringify-without-jsonify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", @@ -12813,6 +12872,16 @@ "graceful-fs": "^4.1.6" } }, + "node_modules/jsonify": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/jsonify/-/jsonify-0.0.1.tgz", + "integrity": "sha512-2/Ki0GcmuqSrgFyelQq9M05y7PS0mEwuIzrf3f1fPqkVDVRvZrPZtVSMHxdgo8Aq0sxAOb/cr2aqqA3LeWHVPg==", + "dev": true, + "license": "Public Domain", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/jsonparse": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/jsonparse/-/jsonparse-1.3.1.tgz", @@ -12886,16 +12955,28 @@ "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } }, + "node_modules/klaw-sync": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/klaw-sync/-/klaw-sync-6.0.0.tgz", + "integrity": "sha512-nIeuVSzdCCs6TDPTqI8w1Yre34sSq7AkZ4B3sfOBbI2CgVSB4Du4aLQijFU2+lhAFCwt9+42Hel6lQNIv6AntQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.1.11" + } + }, "node_modules/known-css-properties": { "version": "0.37.0", "resolved": "https://registry.npmjs.org/known-css-properties/-/known-css-properties-0.37.0.tgz", "integrity": "sha512-JCDrsP4Z1Sb9JwG0aJ8Eo2r7k4Ou5MwmThS/6lcIe1ICyb7UBJKGRIUUdqc2ASdE/42lgz6zFUnzAIhtXnBVrQ==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/koa": { "version": "2.16.3", @@ -13179,7 +13260,8 @@ "resolved": "https://registry.npmjs.org/lodash.truncate/-/lodash.truncate-4.4.2.tgz", "integrity": "sha512-jttmRe7bRse52OsWIMDLaXxWqRAmtIUccAQ3garviCqJjafXOfNMO0yMfNpdD6zbGaTU0P5Nz7e7gAT6cKmJRw==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/lodash.uniq": { "version": "4.5.0", @@ -13272,6 +13354,7 @@ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, "license": "MIT", + "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -13354,6 +13437,7 @@ "integrity": "sha512-APMBEanjybaPzUrfqU0IMU5I0AswKMH7k8OTLs0vvV4KZpExkTkY87nR/zpbuTPj+gARop7aGUbl11pnDfW6xg==", "dev": true, "license": "MIT", + "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/wooorm" @@ -13680,7 +13764,8 @@ "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.12.2.tgz", "integrity": "sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==", "dev": true, - "license": "CC0-1.0" + "license": "CC0-1.0", + "peer": true }, "node_modules/media-typer": { "version": "0.3.0", @@ -14854,6 +14939,7 @@ "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -15422,6 +15508,176 @@ "node": ">= 0.8" } }, + "node_modules/patch-package": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/patch-package/-/patch-package-8.0.1.tgz", + "integrity": "sha512-VsKRIA8f5uqHQ7NGhwIna6Bx6D9s/1iXlA1hthBVBEbkq+t4kXD0HHt+rJhf/Z+Ci0F/HCB2hvn0qLdLG+Qxlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@yarnpkg/lockfile": "^1.1.0", + "chalk": "^4.1.2", + "ci-info": "^3.7.0", + "cross-spawn": "^7.0.3", + "find-yarn-workspace-root": "^2.0.0", + "fs-extra": "^10.0.0", + "json-stable-stringify": "^1.0.2", + "klaw-sync": "^6.0.0", + "minimist": "^1.2.6", + "open": "^7.4.2", + "semver": "^7.5.3", + "slash": "^2.0.0", + "tmp": "^0.2.4", + "yaml": "^2.2.2" + }, + "bin": { + "patch-package": "index.js" + }, + "engines": { + "node": ">=14", + "npm": ">5" + } + }, + "node_modules/patch-package/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/patch-package/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/patch-package/node_modules/ci-info": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", + "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/patch-package/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/patch-package/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/patch-package/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/patch-package/node_modules/is-docker": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", + "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", + "dev": true, + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/patch-package/node_modules/is-wsl": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", + "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-docker": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/patch-package/node_modules/open": { + "version": "7.4.2", + "resolved": "https://registry.npmjs.org/open/-/open-7.4.2.tgz", + "integrity": "sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-docker": "^2.0.0", + "is-wsl": "^2.1.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/patch-package/node_modules/slash": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-2.0.0.tgz", + "integrity": "sha512-ZYKh3Wh2z1PpEXWr0MpSBZ0V6mZHAQfYevttO11c51CaWjGTaadiKZ+wVt1PbMlDV5qhMFslpZCemhwOK7C89A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/path-browserify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", @@ -15602,7 +15858,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -15617,7 +15872,8 @@ "resolved": "https://registry.npmjs.org/postcss-resolve-nested-selector/-/postcss-resolve-nested-selector-0.1.6.tgz", "integrity": "sha512-0sglIs9Wmkzbr8lQwEyIzlDOOC9bGmfVKcJTaxv3vMmd3uo4o4DerC3En0bnmgceeql9BfC8hRkp7cg0fjdVqw==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/postcss-safe-parser": { "version": "7.0.1", @@ -15639,6 +15895,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18.0" }, @@ -15666,7 +15923,8 @@ "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/prelude-ls": { "version": "1.2.1", @@ -15700,6 +15958,7 @@ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -15715,6 +15974,7 @@ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=10" }, @@ -15782,6 +16042,7 @@ "integrity": "sha512-kXuQdQTB6oN3KhI6V4acnBSZx8D2I4xzZvn9+wFLLFCoBNQY/sFnCW6c43OL7pOQ2HvGV4lnWIXNmgfp7cTWhQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "hookified": "^1.13.0" }, @@ -16036,7 +16297,6 @@ "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.69.0.tgz", "integrity": "sha512-yt6ZGME9f4F6WHwevrvpAjh42HMvocuSnSIHUGycBqXIJdhqGSPQzTpGF+1NLREk/58IdPxEMfPcFCjlMhclGw==", "license": "MIT", - "peer": true, "engines": { "node": ">=18.0.0" }, @@ -16091,7 +16351,8 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/react-refresh": { "version": "0.17.0", @@ -16580,8 +16841,7 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/robot3/-/robot3-1.2.0.tgz", "integrity": "sha512-Xin8KHqCKrD9Rqk1ZzZQYjsb6S9DRggcfwBqnVPeM3DLtNCJLxWWTrPJDYm3E+ZiTO7H3VMdgyPSkIbuYnYP2Q==", - "license": "BSD-2-Clause", - "peer": true + "license": "BSD-2-Clause" }, "node_modules/rollup": { "version": "4.53.5", @@ -16589,7 +16849,6 @@ "integrity": "sha512-iTNAbFSlRpcHeeWu73ywU/8KuU/LZmNCSxp6fjQkJBD3ivUb8tpDrXhIxEzA05HlYMEwmtaUnb3RP+YNv162OQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/estree": "1.0.8" }, @@ -17167,7 +17426,8 @@ "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/semver": { "version": "7.7.3", @@ -18006,14 +18266,16 @@ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-2.0.0.tgz", "integrity": "sha512-1ugUSr8BHXRnK23KfuYS+gVMC3LB8QGH9W1iGtDPsNWoQbgtXSExkBu2aDR4epiGWZOjZsj6lDl/N/AqqTC3UA==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/stylelint/node_modules/emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/stylelint/node_modules/file-entry-cache": { "version": "11.1.1", @@ -18021,6 +18283,7 @@ "integrity": "sha512-TPVFSDE7q91Dlk1xpFLvFllf8r0HyOMOlnWy7Z2HBku5H3KhIeOGInexrIeg2D64DosVB/JXkrrk6N/7Wriq4A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "flat-cache": "^6.1.19" } @@ -18031,6 +18294,7 @@ "integrity": "sha512-l/K33newPTZMTGAnnzaiqSl6NnH7Namh8jBNjrgjprWxGmZUuxx/sJNIRaijOh3n7q7ESbhNZC+pvVZMFdeU4A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "cacheable": "^2.2.0", "flatted": "^3.3.3", @@ -18043,6 +18307,7 @@ "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "array-union": "^2.1.0", "dir-glob": "^3.0.1", @@ -18064,6 +18329,7 @@ "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">= 4" } @@ -18074,6 +18340,7 @@ "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">= 4" } @@ -18084,6 +18351,7 @@ "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=8" } @@ -18094,6 +18362,7 @@ "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=8" } @@ -18104,6 +18373,7 @@ "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", @@ -18119,6 +18389,7 @@ "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "ansi-regex": "^5.0.1" }, @@ -18145,6 +18416,7 @@ "integrity": "sha512-zFObLMyZeEwzAoKCyu1B91U79K2t7ApXuQfo8OuxwXLDgcKxuwM+YvcbIhm6QWqz7mHUH1TVytR1PwVVjEuMig==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "has-flag": "^4.0.0", "supports-color": "^7.0.0" @@ -18180,7 +18452,8 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/svg-tags/-/svg-tags-1.0.0.tgz", "integrity": "sha512-ovssysQTa+luh7A5Weu3Rta6FJlFBBbInjOh722LIt6klpU2/HtdUbszju/G4devcvk8PGt7FCLv5wftu3THUA==", - "dev": true + "dev": true, + "peer": true }, "node_modules/symbol-tree": { "version": "3.2.4", @@ -18218,6 +18491,7 @@ "integrity": "sha512-9kY+CygyYM6j02t5YFHbNz2FN5QmYGv9zAjVp4lCDjlCw7amdckXlEt/bjMhUIfj4ThGRE4gCUH5+yGnNuPo5A==", "dev": true, "license": "BSD-3-Clause", + "peer": true, "dependencies": { "ajv": "^8.0.1", "lodash.truncate": "^4.4.2", @@ -18235,6 +18509,7 @@ "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "color-convert": "^2.0.1" }, @@ -18251,6 +18526,7 @@ "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "color-name": "~1.1.4" }, @@ -18263,14 +18539,16 @@ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/table/node_modules/emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/table/node_modules/is-fullwidth-code-point": { "version": "3.0.0", @@ -18278,6 +18556,7 @@ "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=8" } @@ -18288,6 +18567,7 @@ "integrity": "sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "ansi-styles": "^4.0.0", "astral-regex": "^2.0.0", @@ -18306,6 +18586,7 @@ "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", @@ -18321,6 +18602,7 @@ "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "ansi-regex": "^5.0.1" }, @@ -18493,6 +18775,16 @@ "dev": true, "license": "MIT" }, + "node_modules/tmp": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz", + "integrity": "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.14" + } + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -18565,9 +18857,9 @@ } }, "node_modules/ts-api-utils": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.3.0.tgz", - "integrity": "sha512-6eg3Y9SF7SsAvGzRHQvvc1skDAhwI4YQ32ui1scxD1Ccr0G5qIIbUBT3pFTKX8kmWIQClHobtUdNuaBgwdfdWg==", + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz", + "integrity": "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==", "dev": true, "license": "MIT", "engines": { @@ -18657,7 +18949,6 @@ "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" @@ -19056,7 +19347,8 @@ "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/validate-npm-package-license": { "version": "3.0.4", @@ -19137,7 +19429,6 @@ "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", @@ -19956,7 +20247,6 @@ "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/chai": "^5.2.2", "@vitest/expect": "3.2.4", @@ -20403,6 +20693,7 @@ "integrity": "sha512-+QU2zd6OTD8XWIJCbffaiQeH9U73qIqafo1x6V1snCWYGJf6cVE0cDR4D8xRzcEnfI21IFrUPzPGtcPf8AC+Rw==", "dev": true, "license": "ISC", + "peer": true, "dependencies": { "imurmurhash": "^0.1.4", "signal-exit": "^4.0.1" @@ -20614,7 +20905,6 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/package.json b/package.json index 8b579522..e16d0923 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "./CHANGELOG.md" ], "scripts": { + "postinstall": "patch-package", "build": "npm run build:clean && npm run i18n:generate && vite build", "build:ci": "npm run build && npm run lint:check && npm run format:check && npm run test:ci", "build:clean": "rm -rf ./dist && mkdir ./dist", @@ -42,7 +43,7 @@ "watch:vite": "vite build --watch --mode development", "watch:translations": "node ./build/translationWatcher.js", "dev": "node ./build/prompt.js && npm run i18n:generate && npm-run-all --parallel watch:vite watch:translations", - "dev:setup": "npm link ../gws-flows/node_modules/react && (cd ../gws-flows && yarn link -r ../embedded-react-sdk)", + "dev:setup": "npm link ../gws-flows/node_modules/react ../gws-flows/node_modules/react-dom && (cd ../gws-flows && yarn link -r ../embedded-react-sdk)", "docs:events": "npx tsx ./build/eventTypeDocsEmitter.ts", "docs:sync": "npx tsx .docs/src/sync/syncManager.ts", "docs": "npx tsx .docs/src/preview/previewGenerator.ts", @@ -94,6 +95,7 @@ "lint-staged": "^16.2.7", "msw": "^2.12.7", "npm-run-all": "^4.1.5", + "patch-package": "^8.0.1", "prettier": "^3.7.4", "react-dnd": "^16.0.1", "react-dnd-html5-backend": "^16.0.1", diff --git a/patches/@gusto+embedded-api+0.11.4.patch b/patches/@gusto+embedded-api+0.11.4.patch new file mode 100644 index 00000000..e95e0dbf --- /dev/null +++ b/patches/@gusto+embedded-api+0.11.4.patch @@ -0,0 +1,26 @@ +diff --git a/node_modules/@gusto/embedded-api/esm/models/components/payrollemployeecompensationstype.js b/node_modules/@gusto/embedded-api/esm/models/components/payrollemployeecompensationstype.js +index 5655c44..27f2bc2 100644 +--- a/node_modules/@gusto/embedded-api/esm/models/components/payrollemployeecompensationstype.js ++++ b/node_modules/@gusto/embedded-api/esm/models/components/payrollemployeecompensationstype.js +@@ -57,7 +57,7 @@ export function hourlyCompensationsFromJSON(jsonString) { + export const PayrollEmployeeCompensationsTypePaidTimeOff$inboundSchema = z.object({ + name: z.string().optional(), + hours: z.string().optional(), +- final_payout_unused_hours_input: z.string().optional(), ++ final_payout_unused_hours_input: z.nullable(z.string()).optional(), + }).transform((v) => { + return remap$(v, { + "final_payout_unused_hours_input": "finalPayoutUnusedHoursInput", +diff --git a/node_modules/@gusto/embedded-api/src/models/components/payrollemployeecompensationstype.ts b/node_modules/@gusto/embedded-api/src/models/components/payrollemployeecompensationstype.ts +index f96feb5..d1e539a 100644 +--- a/node_modules/@gusto/embedded-api/src/models/components/payrollemployeecompensationstype.ts ++++ b/node_modules/@gusto/embedded-api/src/models/components/payrollemployeecompensationstype.ts +@@ -270,7 +270,7 @@ export const PayrollEmployeeCompensationsTypePaidTimeOff$inboundSchema: + > = z.object({ + name: z.string().optional(), + hours: z.string().optional(), +- final_payout_unused_hours_input: z.string().optional(), ++ final_payout_unused_hours_input: z.nullable(z.string()).optional(), + }).transform((v) => { + return remap$(v, { + "final_payout_unused_hours_input": "finalPayoutUnusedHoursInput", diff --git a/src/components/Terminations/EmployeeTerminations.test.tsx b/src/components/Terminations/EmployeeTerminations.test.tsx index 58cec9d4..1e23a046 100644 --- a/src/components/Terminations/EmployeeTerminations.test.tsx +++ b/src/components/Terminations/EmployeeTerminations.test.tsx @@ -28,11 +28,30 @@ const mockTermination = { cancelable: true, } +const mockTerminationPayPeriods = [ + { + employee_uuid: 'employee-123', + employee_name: 'John Doe', + start_date: '2025-01-01', + end_date: '2025-01-15', + check_date: '2025-01-20', + pay_schedule_uuid: 'pay-schedule-123', + }, +] + +const mockPayrollPrepared = { + payroll_uuid: 'payroll-123', + company_uuid: 'company-123', + off_cycle: true, + off_cycle_reason: 'Dismissed employee', +} + describe('EmployeeTerminations', () => { const onEvent = vi.fn() const user = userEvent.setup() const defaultProps = { employeeId: 'employee-123', + companyId: 'company-123', onEvent, } @@ -47,6 +66,15 @@ describe('EmployeeTerminations', () => { http.post(`${API_BASE_URL}/v1/employees/:employee_id/terminations`, () => { return HttpResponse.json(mockTermination) }), + http.get( + `${API_BASE_URL}/v1/companies/:company_id/pay_periods/unprocessed_termination_pay_periods`, + () => { + return HttpResponse.json(mockTerminationPayPeriods) + }, + ), + http.post(`${API_BASE_URL}/v1/companies/:company_id/payrolls`, () => { + return HttpResponse.json(mockPayrollPrepared) + }), ) }) diff --git a/src/components/Terminations/EmployeeTerminations.tsx b/src/components/Terminations/EmployeeTerminations.tsx index 92a1d4ea..796c8a60 100644 --- a/src/components/Terminations/EmployeeTerminations.tsx +++ b/src/components/Terminations/EmployeeTerminations.tsx @@ -1,6 +1,15 @@ import { useState } from 'react' +import { useQueryClient } from '@tanstack/react-query' import { useEmployeesGetSuspense } from '@gusto/embedded-api/react-query/employeesGet' import { useEmployeeEmploymentsCreateTerminationMutation } from '@gusto/embedded-api/react-query/employeeEmploymentsCreateTermination' +import { usePayrollsCreateOffCycleMutation } from '@gusto/embedded-api/react-query/payrollsCreateOffCycle' +import { + usePaySchedulesGetUnprocessedTerminationPeriods, + invalidateAllPaySchedulesGetUnprocessedTerminationPeriods, +} from '@gusto/embedded-api/react-query/paySchedulesGetUnprocessedTerminationPeriods' +import { invalidateAllPayrollsList } from '@gusto/embedded-api/react-query/payrollsList' +import { OffCycleReason } from '@gusto/embedded-api/models/operations/postv1companiescompanyidpayrolls' +import { RFCDate } from '@gusto/embedded-api/types/rfcdate' import { EmployeeTerminationsPresentation, type PayrollOption, @@ -13,6 +22,7 @@ import { useComponentDictionary, useI18n } from '@/i18n' export interface EmployeeTerminationsProps extends BaseComponentInterface<'Terminations.EmployeeTerminations'> { employeeId: string + companyId: string } export function EmployeeTerminations(props: EmployeeTerminationsProps) { @@ -23,10 +33,11 @@ export function EmployeeTerminations(props: EmployeeTerminationsProps) { ) } -const Root = ({ employeeId, dictionary }: EmployeeTerminationsProps) => { +const Root = ({ employeeId, companyId, dictionary }: EmployeeTerminationsProps) => { useComponentDictionary('Terminations.EmployeeTerminations', dictionary) useI18n('Terminations.EmployeeTerminations') + const queryClient = useQueryClient() const { onEvent, baseSubmitHandler } = useBase() const [lastDayOfWork, setLastDayOfWork] = useState(null) @@ -38,9 +49,17 @@ const Root = ({ employeeId, dictionary }: EmployeeTerminationsProps) => { data: { employee }, } = useEmployeesGetSuspense({ employeeId }) - const { mutateAsync: createTermination, isPending } = + const { mutateAsync: createTermination, isPending: isCreatingTermination } = useEmployeeEmploymentsCreateTerminationMutation() + const { mutateAsync: createOffCyclePayroll, isPending: isCreatingPayroll } = + usePayrollsCreateOffCycleMutation() + + const { refetch: fetchTerminationPeriods } = usePaySchedulesGetUnprocessedTerminationPeriods( + { companyId }, + { enabled: false }, + ) + const employeeName = [employee?.firstName, employee?.lastName].filter(Boolean).join(' ') const validateForm = (): boolean => { @@ -83,6 +102,47 @@ const Root = ({ employeeId, dictionary }: EmployeeTerminationsProps) => { }, }) + if (runTerminationPayroll) { + try { + const { data: terminationPeriodsData } = await fetchTerminationPeriods() + + const terminationPeriod = + terminationPeriodsData?.unprocessedTerminationPayPeriodList?.find( + period => period.employeeUuid === employeeId, + ) + + if (terminationPeriod?.startDate && terminationPeriod.endDate) { + const payrollResult = await createOffCyclePayroll({ + request: { + companyId, + requestBody: { + offCycle: true, + offCycleReason: OffCycleReason.DismissedEmployee, + startDate: new RFCDate(terminationPeriod.startDate), + endDate: new RFCDate(terminationPeriod.endDate), + employeeUuids: [employeeId], + checkDate: terminationPeriod.checkDate + ? new RFCDate(terminationPeriod.checkDate) + : undefined, + }, + }, + }) + + await invalidateAllPayrollsList(queryClient) + await invalidateAllPaySchedulesGetUnprocessedTerminationPeriods(queryClient) + + onEvent(componentEvents.EMPLOYEE_TERMINATION_PAYROLL_CREATED, { + payroll: payrollResult.payrollPrepared, + }) + } + } catch (payrollError) { + onEvent(componentEvents.EMPLOYEE_TERMINATION_PAYROLL_FAILED, { + error: payrollError, + employeeId, + }) + } + } + onEvent(componentEvents.EMPLOYEE_TERMINATION_CREATED, { termination: result.termination, payrollOption, @@ -103,6 +163,8 @@ const Root = ({ employeeId, dictionary }: EmployeeTerminationsProps) => { onEvent(componentEvents.CANCEL) } + const isPending = isCreatingTermination || isCreatingPayroll + return ( { + if ( + payroll.offCycleReason === OffCycleReasonType.DismissedEmployee || + payroll.finalTerminationPayroll + ) { + return 'Dismissal' + } + if (payroll.offCycle) { + return 'Off-Cycle' + } + return 'Regular' +} + +const tableStyles: React.CSSProperties = { + width: '100%', + borderCollapse: 'collapse', + marginBottom: '24px', + fontSize: '14px', +} + +const thStyles: React.CSSProperties = { + textAlign: 'left', + padding: '12px 8px', + borderBottom: '2px solid #e5e7eb', + backgroundColor: '#f9fafb', + fontWeight: 600, +} + +const tdStyles: React.CSSProperties = { + padding: '12px 8px', + borderBottom: '1px solid #e5e7eb', +} + +const sectionStyles: React.CSSProperties = { + marginBottom: '32px', +} + +const headingStyles: React.CSSProperties = { + fontSize: '18px', + fontWeight: 600, + marginBottom: '16px', + color: '#111827', +} + +const badgeStyles: Record = { + Regular: { + display: 'inline-block', + padding: '2px 8px', + borderRadius: '4px', + fontSize: '12px', + fontWeight: 500, + backgroundColor: '#dbeafe', + color: '#1e40af', + }, + 'Off-Cycle': { + display: 'inline-block', + padding: '2px 8px', + borderRadius: '4px', + fontSize: '12px', + fontWeight: 500, + backgroundColor: '#fef3c7', + color: '#92400e', + }, + Dismissal: { + display: 'inline-block', + padding: '2px 8px', + borderRadius: '4px', + fontSize: '12px', + fontWeight: 500, + backgroundColor: '#fee2e2', + color: '#991b1b', + }, +} + +const boolDisplayValue = (value: boolean | null | undefined): string => { + if (value === true) return 'Yes' + if (value === false) return 'No' + return 'N/A' +} + +const tooltipStyles: React.CSSProperties = { + position: 'absolute', + bottom: '100%', + left: '50%', + transform: 'translateX(-50%)', + backgroundColor: '#1f2937', + color: 'white', + padding: '8px 12px', + borderRadius: '6px', + fontSize: '12px', + whiteSpace: 'normal', + zIndex: 1000, + marginBottom: '4px', + boxShadow: '0 4px 6px rgba(0, 0, 0, 0.1)', + maxWidth: '250px', + minWidth: '150px', +} + +const modalOverlayStyles: React.CSSProperties = { + position: 'fixed', + top: 0, + left: 0, + right: 0, + bottom: 0, + backgroundColor: 'rgba(0, 0, 0, 0.5)', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + zIndex: 9999, +} + +const modalContentStyles: React.CSSProperties = { + backgroundColor: 'white', + borderRadius: '12px', + padding: '24px', + maxWidth: '600px', + width: '90%', + maxHeight: '90vh', + overflow: 'auto', + boxShadow: '0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04)', +} + +const selectStyles: React.CSSProperties = { + width: '100%', + padding: '10px 12px', + fontSize: '14px', + border: '1px solid #d1d5db', + borderRadius: '6px', + backgroundColor: 'white', + cursor: 'pointer', + marginBottom: '20px', +} + +type EmployeeInfo = { + uuid: string + firstName: string | null | undefined + lastName: string | null | undefined + excluded?: boolean +} + +type PayrollEmployeeData = { + loading: boolean + loaded: boolean + employees: EmployeeInfo[] + error?: string +} + +function EmployeeCountCell({ payroll, companyId }: { payroll: Payroll; companyId: string }) { + const gustoEmbedded = useGustoEmbeddedContext() + const [employeeData, setEmployeeData] = useState({ + loading: false, + loaded: false, + employees: [], + }) + const [showTooltip, setShowTooltip] = useState(false) + + const loadEmployees = async () => { + if (employeeData.loaded || employeeData.loading) return + + setEmployeeData(prev => ({ ...prev, loading: true })) + + const payrollId = payroll.payrollUuid || payroll.uuid + if (!payrollId) { + setEmployeeData({ loading: false, loaded: true, employees: [], error: 'No payroll ID' }) + return + } + + const result = await payrollsPrepare(gustoEmbedded, { companyId, payrollId }) + + if (!result.ok) { + setEmployeeData({ + loading: false, + loaded: true, + employees: [], + error: 'Failed to load', + }) + return + } + + const preparedPayroll = result.value as { payrollPrepared?: PayrollPrepared } + const employeeCompensations = preparedPayroll.payrollPrepared?.employeeCompensations || [] + + const employees: EmployeeInfo[] = employeeCompensations.map(ec => ({ + uuid: ec.employeeUuid || '', + firstName: ec.firstName, + lastName: ec.lastName, + excluded: ec.excluded, + })) + + setEmployeeData({ + loading: false, + loaded: true, + employees, + }) + } + + const getDisplayText = () => { + if (employeeData.loading) return 'Loading...' + if (employeeData.error) return 'Error' + if (employeeData.loaded) { + const active = employeeData.employees.filter(e => !e.excluded).length + const total = employeeData.employees.length + return active === total ? `${total}` : `${active}/${total}` + } + return 'Click to load' + } + + const renderEmployeeList = () => { + return ( +
    + {employeeData.employees.map((e, idx) => { + const name = `${e.firstName || ''} ${e.lastName || ''}`.trim() || 'Unknown' + return ( +
  • + {e.excluded ? `${name} (excluded)` : name} +
  • + ) + })} +
+ ) + } + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + void loadEmployees() + } + } + + return ( + + + + ) +} + +function PayrollTable({ payrolls, companyId }: { payrolls: Payroll[]; companyId: string }) { + if (payrolls.length === 0) { + return

No payrolls found.

+ } + + return ( + + + + + + + + + + + + + + + + {payrolls.map(payroll => { + const category = getPayrollCategory(payroll) + const payPeriodDisplay = payroll.payPeriod + ? `${payroll.payPeriod.startDate} - ${payroll.payPeriod.endDate}` + : 'N/A' + + return ( + + + + + + + + + + + + ) + })} + +
Pay PeriodCheck DateTypeEmployeesOff-CycleOff-Cycle ReasonFinal TermStatusPayroll ID
{payPeriodDisplay}{payroll.checkDate || 'N/A'} + {category} + {boolDisplayValue(payroll.offCycle)}{payroll.offCycleReason || '-'}{boolDisplayValue(payroll.finalTerminationPayroll)}{payroll.processed ? 'Processed' : 'Unprocessed'} + {payroll.payrollUuid || payroll.uuid} +
+ ) +} + +function TerminationPayPeriodsTable({ periods }: { periods: UnprocessedTerminationPayPeriod[] }) { + if (periods.length === 0) { + return ( +

+ No unprocessed termination pay periods found. +

+ ) + } + + return ( + + + + + + + + + + + + {periods.map((period, index) => { + const payPeriodDisplay = + period.startDate && period.endDate ? `${period.startDate} - ${period.endDate}` : 'N/A' + + return ( + + + + + + + + ) + })} + +
EmployeePay PeriodCheck DateDebit DateEmployee ID
{period.employeeName || 'Unknown'}{payPeriodDisplay}{period.checkDate || 'N/A'}{period.debitDate || 'N/A'} + {period.employeeUuid} +
+ ) +} + +function TerminatedEmployeesTable({ employees }: { employees: ShowEmployees[] }) { + if (employees.length === 0) { + return

No terminated employees found.

+ } + + return ( + + + + + + + + + + + + + {employees.map(employee => { + const termination = employee.terminations?.[0] + const employeeName = + `${employee.firstName || ''} ${employee.lastName || ''}`.trim() || 'Unknown' + + return ( + + + + + + + + + ) + })} + +
EmployeeEffective DateActiveDismissal PayrollCancelableEmployee ID
{employeeName}{termination?.effectiveDate || 'N/A'} + + {termination?.active ? 'Terminated' : 'Scheduled'} + + {boolDisplayValue(termination?.runTerminationPayroll)}{boolDisplayValue(termination?.cancelable)} + {employee.uuid} +
+ ) +} + +interface TerminationModalProps { + isOpen: boolean + onClose: () => void + companyId: string + activeEmployees: ShowEmployees[] + onTerminationComplete: () => void +} + +function TerminationModal({ + isOpen, + onClose, + companyId, + activeEmployees, + onTerminationComplete, +}: TerminationModalProps) { + const [selectedEmployeeId, setSelectedEmployeeId] = useState('') + + if (!isOpen) return null + + const handleClose = () => { + onClose() + setSelectedEmployeeId('') + } + + const handleTerminationEvent = (event: string) => { + if (event === 'employee/termination/done' || event === 'cancel') { + onTerminationComplete() + handleClose() + } + } + + return ( +
+ +
+ +
+ + +
+ + {selectedEmployeeId ? ( +
+ + Loading employee data... +
+ } + > + + + + ) : ( +

+ Select an employee to begin the termination process. +

+ )} + + + ) +} + +function TerminationsDataContent({ companyId }: TerminationsDataProps) { + const queryClient = useQueryClient() + const [isModalOpen, setIsModalOpen] = useState(false) + + const today = new Date() + const threeMonthsAgo = new Date(today.getFullYear(), today.getMonth() - 3, today.getDate()) + const threeMonthsFromNow = new Date(today.getFullYear(), today.getMonth() + 3, today.getDate()) + const formatDate = (date: Date) => date.toISOString().split('T')[0] + + const { data: payrollsData, refetch: refetchPayrolls } = usePayrollsListSuspense({ + companyId, + processingStatuses: [ProcessingStatuses.Unprocessed], + payrollTypes: [PayrollTypes.Regular, PayrollTypes.OffCycle], + includeOffCycle: true, + startDate: formatDate(threeMonthsAgo), + endDate: formatDate(threeMonthsFromNow), + }) + + const { data: terminationPeriodsData } = usePaySchedulesGetUnprocessedTerminationPeriodsSuspense({ + companyId, + }) + + const { data: terminatedEmployeesData } = useEmployeesListSuspense({ + companyId, + terminated: true, + }) + + const { data: activeEmployeesData } = useEmployeesListSuspense({ + companyId, + terminated: false, + onboarded: true, + }) + + const handleRefresh = () => { + void refetchPayrolls() + } + + const handleTerminationComplete = () => { + void invalidateAllEmployeesList(queryClient) + void refetchPayrolls() + } + + const payrollList = payrollsData.payrollList || [] + const terminationPeriods = terminationPeriodsData.unprocessedTerminationPayPeriodList || [] + const terminatedEmployees = terminatedEmployeesData.showEmployees || [] + const activeEmployees = activeEmployeesData.showEmployees || [] + + const regularPayrolls = payrollList.filter(p => getPayrollCategory(p) === 'Regular') + const offCyclePayrolls = payrollList.filter(p => getPayrollCategory(p) === 'Off-Cycle') + const dismissalPayrolls = payrollList.filter(p => getPayrollCategory(p) === 'Dismissal') + + return ( +
+
+

+ Terminations Data - Payroll Overview +

+
+ + +
+
+ +
+
+
{payrollList.length}
+
Total Unprocessed
+
+
+
{regularPayrolls.length}
+
Regular
+
+
+
{offCyclePayrolls.length}
+
Off-Cycle
+
+
+
{dismissalPayrolls.length}
+
Dismissal
+
+
+ +
+

All Unprocessed Payrolls

+ +
+ +
+

Regular Payrolls

+ +
+ +
+

Off-Cycle Payrolls

+ +
+ +
+

Dismissal Payrolls

+ +
+ +
+

Unprocessed Termination Pay Periods

+

+ These are pay periods for employees who selected "Dismissal Payroll" as their + final payroll option. Match the pay period dates with the payrolls above to identify which + payroll corresponds to each terminated employee. +

+ +
+ +
+

All Terminated Employees ({terminatedEmployees.length})

+

+ All employees who have been terminated or are scheduled to be terminated. +

+ +
+ + { + setIsModalOpen(false) + }} + companyId={companyId} + activeEmployees={activeEmployees} + onTerminationComplete={handleTerminationComplete} + /> +
+ ) +} + +export function TerminationsData({ companyId }: TerminationsDataProps) { + return ( + Loading payroll data...} + > + + + ) +} diff --git a/src/components/Terminations/index.tsx b/src/components/Terminations/index.tsx index 7507753e..ede5d45e 100644 --- a/src/components/Terminations/index.tsx +++ b/src/components/Terminations/index.tsx @@ -1 +1,2 @@ export { EmployeeTerminations } from './EmployeeTerminations' +export { TerminationsData } from './TerminationsData' diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 116d2a63..5b2528de 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -123,6 +123,8 @@ export const contractorPaymentEvents = { export const terminationEvents = { EMPLOYEE_TERMINATION_CREATED: 'employee/termination/created', + EMPLOYEE_TERMINATION_PAYROLL_CREATED: 'employee/termination/payroll/created', + EMPLOYEE_TERMINATION_PAYROLL_FAILED: 'employee/termination/payroll/failed', EMPLOYEE_TERMINATION_DONE: 'employee/termination/done', } as const From e53f09734598d74ee2ba9c1f7506ae6d6f9c84fe Mon Sep 17 00:00:00 2001 From: Aaron Lee Date: Tue, 30 Dec 2025 16:14:23 -0500 Subject: [PATCH 04/12] fix: handle multiple dismissal payrolls for employee --- .../Terminations/EmployeeTerminations.tsx | 48 +++++++++++-------- 1 file changed, 28 insertions(+), 20 deletions(-) diff --git a/src/components/Terminations/EmployeeTerminations.tsx b/src/components/Terminations/EmployeeTerminations.tsx index 796c8a60..a3868bfa 100644 --- a/src/components/Terminations/EmployeeTerminations.tsx +++ b/src/components/Terminations/EmployeeTerminations.tsx @@ -106,33 +106,41 @@ const Root = ({ employeeId, companyId, dictionary }: EmployeeTerminationsProps) try { const { data: terminationPeriodsData } = await fetchTerminationPeriods() - const terminationPeriod = - terminationPeriodsData?.unprocessedTerminationPayPeriodList?.find( + const employeePeriods = + terminationPeriodsData?.unprocessedTerminationPayPeriodList?.filter( period => period.employeeUuid === employeeId, - ) - - if (terminationPeriod?.startDate && terminationPeriod.endDate) { - const payrollResult = await createOffCyclePayroll({ - request: { - companyId, - requestBody: { - offCycle: true, - offCycleReason: OffCycleReason.DismissedEmployee, - startDate: new RFCDate(terminationPeriod.startDate), - endDate: new RFCDate(terminationPeriod.endDate), - employeeUuids: [employeeId], - checkDate: terminationPeriod.checkDate - ? new RFCDate(terminationPeriod.checkDate) - : undefined, + ) ?? [] + + const createdPayrolls = [] + + for (const terminationPeriod of employeePeriods) { + if (terminationPeriod.startDate && terminationPeriod.endDate) { + const payrollResult = await createOffCyclePayroll({ + request: { + companyId, + requestBody: { + offCycle: true, + offCycleReason: OffCycleReason.DismissedEmployee, + startDate: new RFCDate(terminationPeriod.startDate), + endDate: new RFCDate(terminationPeriod.endDate), + employeeUuids: [employeeId], + checkDate: terminationPeriod.checkDate + ? new RFCDate(terminationPeriod.checkDate) + : undefined, + }, }, - }, - }) + }) + + createdPayrolls.push(payrollResult.payrollPrepared) + } + } + if (createdPayrolls.length > 0) { await invalidateAllPayrollsList(queryClient) await invalidateAllPaySchedulesGetUnprocessedTerminationPeriods(queryClient) onEvent(componentEvents.EMPLOYEE_TERMINATION_PAYROLL_CREATED, { - payroll: payrollResult.payrollPrepared, + payrolls: createdPayrolls, }) } } catch (payrollError) { From 7b5ce85330b1819dc9a3a8b86c4249419c0f7245 Mon Sep 17 00:00:00 2001 From: Aaron Lee Date: Wed, 31 Dec 2025 11:27:06 -0500 Subject: [PATCH 05/12] fix: add mock data mode to UI for unprocessed pay periods --- .../Terminations/TerminationsData.tsx | 440 ++++++++++++++++-- 1 file changed, 400 insertions(+), 40 deletions(-) diff --git a/src/components/Terminations/TerminationsData.tsx b/src/components/Terminations/TerminationsData.tsx index ebd23c53..61d14373 100644 --- a/src/components/Terminations/TerminationsData.tsx +++ b/src/components/Terminations/TerminationsData.tsx @@ -21,6 +21,178 @@ import { EmployeeTerminations } from './EmployeeTerminations' interface TerminationsDataProps { companyId: string + useMockData?: boolean +} + +const MOCK_TERMINATION_PERIODS: UnprocessedTerminationPayPeriod[] = [ + { + employeeUuid: 'mock-employee-past-123', + employeeName: 'John Doe (Past Termination - 3 weeks ago)', + startDate: '2024-12-01', + endDate: '2024-12-07', + checkDate: '2024-12-10', + debitDate: '2024-12-08', + payScheduleUuid: 'mock-pay-schedule-1', + }, + { + employeeUuid: 'mock-employee-past-123', + employeeName: 'John Doe (Past Termination - 3 weeks ago)', + startDate: '2024-12-08', + endDate: '2024-12-14', + checkDate: '2024-12-17', + debitDate: '2024-12-15', + payScheduleUuid: 'mock-pay-schedule-1', + }, + { + employeeUuid: 'mock-employee-past-123', + employeeName: 'John Doe (Past Termination - 3 weeks ago)', + startDate: '2024-12-15', + endDate: '2024-12-21', + checkDate: '2024-12-24', + debitDate: '2024-12-22', + payScheduleUuid: 'mock-pay-schedule-1', + }, + { + employeeUuid: 'mock-employee-recent-456', + employeeName: 'Jane Smith (Recent Termination)', + startDate: '2024-12-22', + endDate: '2024-12-28', + checkDate: '2024-12-31', + debitDate: '2024-12-29', + payScheduleUuid: 'mock-pay-schedule-2', + }, +] + +const MOCK_TERMINATED_EMPLOYEES: ShowEmployees[] = [ + { + uuid: 'mock-employee-past-123', + firstName: 'John', + lastName: 'Doe', + terminated: true, + terminations: [ + { + effectiveDate: '2024-12-01', + active: true, + runTerminationPayroll: true, + cancelable: false, + }, + ], + } as ShowEmployees, + { + uuid: 'mock-employee-recent-456', + firstName: 'Jane', + lastName: 'Smith', + terminated: true, + terminations: [ + { + effectiveDate: '2024-12-22', + active: true, + runTerminationPayroll: true, + cancelable: true, + }, + ], + } as ShowEmployees, +] + +const MOCK_DISMISSAL_PAYROLLS: Payroll[] = [ + { + payrollUuid: 'mock-payroll-dismissal-1', + companyUuid: 'mock-company', + processed: false, + offCycle: true, + offCycleReason: OffCycleReasonType.DismissedEmployee, + checkDate: '2024-12-10', + payPeriod: { + startDate: '2024-12-01', + endDate: '2024-12-07', + }, + finalTerminationPayroll: true, + } as Payroll, + { + payrollUuid: 'mock-payroll-dismissal-2', + companyUuid: 'mock-company', + processed: false, + offCycle: true, + offCycleReason: OffCycleReasonType.DismissedEmployee, + checkDate: '2024-12-17', + payPeriod: { + startDate: '2024-12-08', + endDate: '2024-12-14', + }, + finalTerminationPayroll: true, + } as Payroll, + { + payrollUuid: 'mock-payroll-dismissal-3', + companyUuid: 'mock-company', + processed: false, + offCycle: true, + offCycleReason: OffCycleReasonType.DismissedEmployee, + checkDate: '2024-12-24', + payPeriod: { + startDate: '2024-12-15', + endDate: '2024-12-21', + }, + finalTerminationPayroll: true, + } as Payroll, + { + payrollUuid: 'mock-payroll-dismissal-4', + companyUuid: 'mock-company', + processed: false, + offCycle: true, + offCycleReason: OffCycleReasonType.DismissedEmployee, + checkDate: '2024-12-31', + payPeriod: { + startDate: '2024-12-22', + endDate: '2024-12-28', + }, + finalTerminationPayroll: true, + } as Payroll, +] + +type EmployeeInfo = { + uuid: string + firstName: string | null | undefined + lastName: string | null | undefined + excluded?: boolean +} + +const MOCK_EMPLOYEES_BY_PAYROLL: Record = { + 'mock-payroll-dismissal-1': [ + { uuid: 'mock-employee-past-123', firstName: 'John', lastName: 'Doe', excluded: false }, + ], + 'mock-payroll-dismissal-2': [ + { uuid: 'mock-employee-past-123', firstName: 'John', lastName: 'Doe', excluded: false }, + ], + 'mock-payroll-dismissal-3': [ + { uuid: 'mock-employee-past-123', firstName: 'John', lastName: 'Doe', excluded: false }, + ], + 'mock-payroll-dismissal-4': [ + { uuid: 'mock-employee-recent-456', firstName: 'Jane', lastName: 'Smith', excluded: false }, + ], +} + +const mockDataBannerStyles: React.CSSProperties = { + backgroundColor: '#fef3c7', + border: '2px dashed #f59e0b', + borderRadius: '8px', + padding: '12px 16px', + marginBottom: '24px', + display: 'flex', + alignItems: 'center', + gap: '12px', +} + +const mockBadgeStyles: React.CSSProperties = { + display: 'inline-block', + padding: '2px 8px', + borderRadius: '4px', + fontSize: '11px', + fontWeight: 600, + backgroundColor: '#f59e0b', + color: 'white', + marginLeft: '8px', + textTransform: 'uppercase', + letterSpacing: '0.5px', } type PayrollCategory = 'Regular' | 'Off-Cycle' | 'Dismissal' @@ -158,13 +330,6 @@ const selectStyles: React.CSSProperties = { marginBottom: '20px', } -type EmployeeInfo = { - uuid: string - firstName: string | null | undefined - lastName: string | null | undefined - excluded?: boolean -} - type PayrollEmployeeData = { loading: boolean loaded: boolean @@ -172,21 +337,42 @@ type PayrollEmployeeData = { error?: string } -function EmployeeCountCell({ payroll, companyId }: { payroll: Payroll; companyId: string }) { +function EmployeeCountCell({ + payroll, + companyId, + isMockData, +}: { + payroll: Payroll + companyId: string + isMockData?: boolean +}) { const gustoEmbedded = useGustoEmbeddedContext() - const [employeeData, setEmployeeData] = useState({ - loading: false, - loaded: false, - employees: [], + const payrollId = payroll.payrollUuid || payroll.uuid + const isMockPayroll = payrollId?.startsWith('mock-') + + const mockEmployees = isMockPayroll && payrollId ? MOCK_EMPLOYEES_BY_PAYROLL[payrollId] || [] : [] + + const [employeeData, setEmployeeData] = useState(() => { + if (isMockPayroll) { + return { + loading: false, + loaded: true, + employees: mockEmployees, + } + } + return { + loading: false, + loaded: false, + employees: [], + } }) const [showTooltip, setShowTooltip] = useState(false) const loadEmployees = async () => { - if (employeeData.loaded || employeeData.loading) return + if (isMockPayroll || employeeData.loaded || employeeData.loading) return setEmployeeData(prev => ({ ...prev, loading: true })) - const payrollId = payroll.payrollUuid || payroll.uuid if (!payrollId) { setEmployeeData({ loading: false, loaded: true, employees: [], error: 'No payroll ID' }) return @@ -291,7 +477,15 @@ function EmployeeCountCell({ payroll, companyId }: { payroll: Payroll; companyId ) } -function PayrollTable({ payrolls, companyId }: { payrolls: Payroll[]; companyId: string }) { +function PayrollTable({ + payrolls, + companyId, + isMockData, +}: { + payrolls: Payroll[] + companyId: string + isMockData?: boolean +}) { if (payrolls.length === 0) { return

No payrolls found.

} @@ -309,6 +503,7 @@ function PayrollTable({ payrolls, companyId }: { payrolls: Payroll[]; companyId: Final Term Status Payroll ID + {isMockData && Source} @@ -317,15 +512,22 @@ function PayrollTable({ payrolls, companyId }: { payrolls: Payroll[]; companyId: const payPeriodDisplay = payroll.payPeriod ? `${payroll.payPeriod.startDate} - ${payroll.payPeriod.endDate}` : 'N/A' + const isMockRow = payroll.payrollUuid?.startsWith('mock-') return ( - - {payPeriodDisplay} + + + {payPeriodDisplay} + {isMockRow && Mock} + {payroll.checkDate || 'N/A'} {category} - + {boolDisplayValue(payroll.offCycle)} {payroll.offCycleReason || '-'} {boolDisplayValue(payroll.finalTerminationPayroll)} @@ -333,6 +535,19 @@ function PayrollTable({ payrolls, companyId }: { payrolls: Payroll[]; companyId: {payroll.payrollUuid || payroll.uuid} + {isMockData && ( + + + {isMockRow ? 'Mock' : 'Real'} + + + )} ) })} @@ -341,7 +556,13 @@ function PayrollTable({ payrolls, companyId }: { payrolls: Payroll[]; companyId: ) } -function TerminationPayPeriodsTable({ periods }: { periods: UnprocessedTerminationPayPeriod[] }) { +function TerminationPayPeriodsTable({ + periods, + isMockData, +}: { + periods: UnprocessedTerminationPayPeriod[] + isMockData?: boolean +}) { if (periods.length === 0) { return (

@@ -359,22 +580,43 @@ function TerminationPayPeriodsTable({ periods }: { periods: UnprocessedTerminati Check Date Debit Date Employee ID + {isMockData && Source} {periods.map((period, index) => { const payPeriodDisplay = period.startDate && period.endDate ? `${period.startDate} - ${period.endDate}` : 'N/A' + const isMockRow = period.employeeUuid?.startsWith('mock-') return ( - - {period.employeeName || 'Unknown'} + + + {period.employeeName || 'Unknown'} + {isMockRow && Mock} + {payPeriodDisplay} {period.checkDate || 'N/A'} {period.debitDate || 'N/A'} {period.employeeUuid} + {isMockData && ( + + + {isMockRow ? 'Mock' : 'Real'} + + + )} ) })} @@ -383,7 +625,13 @@ function TerminationPayPeriodsTable({ periods }: { periods: UnprocessedTerminati ) } -function TerminatedEmployeesTable({ employees }: { employees: ShowEmployees[] }) { +function TerminatedEmployeesTable({ + employees, + isMockData, +}: { + employees: ShowEmployees[] + isMockData?: boolean +}) { if (employees.length === 0) { return

No terminated employees found.

} @@ -398,6 +646,7 @@ function TerminatedEmployeesTable({ employees }: { employees: ShowEmployees[] }) Dismissal Payroll Cancelable Employee ID + {isMockData && Source} @@ -405,10 +654,14 @@ function TerminatedEmployeesTable({ employees }: { employees: ShowEmployees[] }) const termination = employee.terminations?.[0] const employeeName = `${employee.firstName || ''} ${employee.lastName || ''}`.trim() || 'Unknown' + const isMockRow = employee.uuid.startsWith('mock-') return ( - - {employeeName} + + + {employeeName} + {isMockRow && Mock} + {termination?.effectiveDate || 'N/A'} {employee.uuid} + {isMockData && ( + + + {isMockRow ? 'Mock' : 'Real'} + + + )} ) })} @@ -579,9 +845,10 @@ function TerminationModal({ ) } -function TerminationsDataContent({ companyId }: TerminationsDataProps) { +function TerminationsDataContent({ companyId, useMockData }: TerminationsDataProps) { const queryClient = useQueryClient() const [isModalOpen, setIsModalOpen] = useState(false) + const [showMockData, setShowMockData] = useState(useMockData ?? false) const today = new Date() const threeMonthsAgo = new Date(today.getFullYear(), today.getMonth() - 3, today.getDate()) @@ -621,15 +888,31 @@ function TerminationsDataContent({ companyId }: TerminationsDataProps) { void refetchPayrolls() } - const payrollList = payrollsData.payrollList || [] - const terminationPeriods = terminationPeriodsData.unprocessedTerminationPayPeriodList || [] - const terminatedEmployees = terminatedEmployeesData.showEmployees || [] + const realPayrollList = payrollsData.payrollList || [] + const realTerminationPeriods = terminationPeriodsData.unprocessedTerminationPayPeriodList || [] + const realTerminatedEmployees = terminatedEmployeesData.showEmployees || [] const activeEmployees = activeEmployeesData.showEmployees || [] + const payrollList = showMockData + ? [...MOCK_DISMISSAL_PAYROLLS, ...realPayrollList] + : realPayrollList + + const terminationPeriods = showMockData + ? [...MOCK_TERMINATION_PERIODS, ...realTerminationPeriods] + : realTerminationPeriods + + const terminatedEmployees = showMockData + ? [...MOCK_TERMINATED_EMPLOYEES, ...realTerminatedEmployees] + : realTerminatedEmployees + const regularPayrolls = payrollList.filter(p => getPayrollCategory(p) === 'Regular') const offCyclePayrolls = payrollList.filter(p => getPayrollCategory(p) === 'Off-Cycle') const dismissalPayrolls = payrollList.filter(p => getPayrollCategory(p) === 'Dismissal') + const mockPeriodCount = showMockData ? MOCK_TERMINATION_PERIODS.length : 0 + const mockEmployeeCount = showMockData ? MOCK_TERMINATED_EMPLOYEES.length : 0 + const mockPayrollCount = showMockData ? MOCK_DISMISSAL_PAYROLLS.length : 0 + return (
+
+ {showMockData && ( +
+ 🧪 +
+ Mock Data Mode Enabled +

+ Simulating a past-dated termination scenario: John Doe was terminated 3 weeks ago with{' '} + {mockPeriodCount} unprocessed pay periods and{' '} + {mockPayrollCount} corresponding dismissal payrolls (one for each + period). This demonstrates how our EmployeeTerminations component creates separate + payrolls for each unprocessed period. Mock rows are highlighted in yellow with a + "MOCK" badge. +

+
+
+ )} +
-

All Unprocessed Payrolls

- +

+ All Unprocessed Payrolls + {showMockData && ( + + +{mockPayrollCount} Mock Dismissal + + )} +

+

Regular Payrolls

- +

Off-Cycle Payrolls

- +
-

Dismissal Payrolls

- +

+ Dismissal Payrolls + {showMockData && ( + +{mockPayrollCount} Mock + )} +

+

+ Off-cycle payrolls created for dismissed employees. + {showMockData && ( + + {' '} + These 3 mock payrolls correspond to John Doe's 3 unprocessed pay periods - each + created by our EmployeeTerminations component. + + )} +

+
-

Unprocessed Termination Pay Periods

+

+ Unprocessed Termination Pay Periods + {showMockData && ( + +{mockPeriodCount} Mock + )} +

These are pay periods for employees who selected "Dismissal Payroll" as their final payroll option. Match the pay period dates with the payrolls above to identify which payroll corresponds to each terminated employee. + {showMockData && ( + + {' '} + Note: John Doe has 3 unprocessed pay periods (simulating a past-dated termination). + + )}

- +
-

All Terminated Employees ({terminatedEmployees.length})

+

+ All Terminated Employees ({terminatedEmployees.length}) + {showMockData && ( + +{mockEmployeeCount} Mock + )} +

All employees who have been terminated or are scheduled to be terminated.

- +
Loading payroll data...
} > - + ) } From 1792d35b37fd34982439139c144929229a411a80 Mon Sep 17 00:00:00 2001 From: Aaron Lee Date: Wed, 31 Dec 2025 11:59:54 -0500 Subject: [PATCH 06/12] fix: renaming and folder structure changes --- .../TerminateEmployee.test.tsx} | 26 +++++++++---------- .../TerminateEmployee.tsx} | 17 +++++------- .../TerminateEmployeePresentation.tsx} | 15 ++++++----- .../Terminations/TerminationsData.tsx | 4 +-- src/components/Terminations/index.tsx | 2 +- ...on => Terminations.TerminateEmployee.json} | 5 ++-- src/types/i18next.d.ts | 7 ++--- 7 files changed, 38 insertions(+), 38 deletions(-) rename src/components/Terminations/{EmployeeTerminations.test.tsx => TerminateEmployee/TerminateEmployee.test.tsx} (87%) rename src/components/Terminations/{EmployeeTerminations.tsx => TerminateEmployee/TerminateEmployee.tsx} (91%) rename src/components/Terminations/{EmployeeTerminationsPresentation.tsx => TerminateEmployee/TerminateEmployeePresentation.tsx} (86%) rename src/i18n/en/{Terminations.EmployeeTerminations.json => Terminations.TerminateEmployee.json} (72%) diff --git a/src/components/Terminations/EmployeeTerminations.test.tsx b/src/components/Terminations/TerminateEmployee/TerminateEmployee.test.tsx similarity index 87% rename from src/components/Terminations/EmployeeTerminations.test.tsx rename to src/components/Terminations/TerminateEmployee/TerminateEmployee.test.tsx index 1e23a046..324f3530 100644 --- a/src/components/Terminations/EmployeeTerminations.test.tsx +++ b/src/components/Terminations/TerminateEmployee/TerminateEmployee.test.tsx @@ -2,7 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { http, HttpResponse } from 'msw' -import { EmployeeTerminations } from './EmployeeTerminations' +import { TerminateEmployee } from './TerminateEmployee' import { server } from '@/test/mocks/server' import { componentEvents } from '@/shared/constants' import { setupApiTestMocks } from '@/test/mocks/apiServer' @@ -46,7 +46,7 @@ const mockPayrollPrepared = { off_cycle_reason: 'Dismissed employee', } -describe('EmployeeTerminations', () => { +describe('TerminateEmployee', () => { const onEvent = vi.fn() const user = userEvent.setup() const defaultProps = { @@ -80,7 +80,7 @@ describe('EmployeeTerminations', () => { describe('rendering', () => { it('renders the termination form with employee name', async () => { - renderWithProviders() + renderWithProviders() await waitFor(() => { expect(screen.getByRole('heading', { name: 'Terminate John Doe' })).toBeInTheDocument() @@ -92,7 +92,7 @@ describe('EmployeeTerminations', () => { }) it('renders the date picker for last day of work', async () => { - renderWithProviders() + renderWithProviders() await waitFor(() => { expect(screen.getByText('Last day of work')).toBeInTheDocument() @@ -100,7 +100,7 @@ describe('EmployeeTerminations', () => { }) it('renders all payroll options', async () => { - renderWithProviders() + renderWithProviders() await waitFor(() => { expect(screen.getByText('Dismissal payroll')).toBeInTheDocument() @@ -111,7 +111,7 @@ describe('EmployeeTerminations', () => { }) it('renders submit and cancel buttons', async () => { - renderWithProviders() + renderWithProviders() await waitFor(() => { expect(screen.getByRole('button', { name: 'Terminate employee' })).toBeInTheDocument() @@ -123,7 +123,7 @@ describe('EmployeeTerminations', () => { describe('form validation', () => { it('shows validation error when submitting without date', async () => { - renderWithProviders() + renderWithProviders() await waitFor(() => { expect(screen.getByRole('button', { name: 'Terminate employee' })).toBeInTheDocument() @@ -137,7 +137,7 @@ describe('EmployeeTerminations', () => { }) it('shows validation error when submitting without payroll option', async () => { - renderWithProviders() + renderWithProviders() await waitFor(() => { expect(screen.getByRole('button', { name: 'Terminate employee' })).toBeInTheDocument() @@ -155,7 +155,7 @@ describe('EmployeeTerminations', () => { describe('cancel action', () => { it('emits CANCEL event when cancel button is clicked', async () => { - renderWithProviders() + renderWithProviders() await waitFor(() => { expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument() @@ -169,7 +169,7 @@ describe('EmployeeTerminations', () => { describe('payroll option selection', () => { it('allows selecting dismissal payroll option', async () => { - renderWithProviders() + renderWithProviders() await waitFor(() => { expect(screen.getByText('Dismissal payroll')).toBeInTheDocument() @@ -182,7 +182,7 @@ describe('EmployeeTerminations', () => { }) it('allows selecting regular payroll option', async () => { - renderWithProviders() + renderWithProviders() await waitFor(() => { expect(screen.getByText('Regular payroll')).toBeInTheDocument() @@ -195,7 +195,7 @@ describe('EmployeeTerminations', () => { }) it('allows selecting another way option', async () => { - renderWithProviders() + renderWithProviders() await waitFor(() => { expect(screen.getByText('Another way')).toBeInTheDocument() @@ -208,7 +208,7 @@ describe('EmployeeTerminations', () => { }) it('shows option descriptions', async () => { - renderWithProviders() + renderWithProviders() await waitFor(() => { expect( diff --git a/src/components/Terminations/EmployeeTerminations.tsx b/src/components/Terminations/TerminateEmployee/TerminateEmployee.tsx similarity index 91% rename from src/components/Terminations/EmployeeTerminations.tsx rename to src/components/Terminations/TerminateEmployee/TerminateEmployee.tsx index a3868bfa..a15bc874 100644 --- a/src/components/Terminations/EmployeeTerminations.tsx +++ b/src/components/Terminations/TerminateEmployee/TerminateEmployee.tsx @@ -10,22 +10,19 @@ import { import { invalidateAllPayrollsList } from '@gusto/embedded-api/react-query/payrollsList' import { OffCycleReason } from '@gusto/embedded-api/models/operations/postv1companiescompanyidpayrolls' import { RFCDate } from '@gusto/embedded-api/types/rfcdate' -import { - EmployeeTerminationsPresentation, - type PayrollOption, -} from './EmployeeTerminationsPresentation' +import { TerminateEmployeePresentation, type PayrollOption } from './TerminateEmployeePresentation' import type { BaseComponentInterface } from '@/components/Base/Base' import { BaseComponent } from '@/components/Base/Base' import { useBase } from '@/components/Base/useBase' import { componentEvents } from '@/shared/constants' import { useComponentDictionary, useI18n } from '@/i18n' -export interface EmployeeTerminationsProps extends BaseComponentInterface<'Terminations.EmployeeTerminations'> { +export interface TerminateEmployeeProps extends BaseComponentInterface<'Terminations.TerminateEmployee'> { employeeId: string companyId: string } -export function EmployeeTerminations(props: EmployeeTerminationsProps) { +export function TerminateEmployee(props: TerminateEmployeeProps) { return ( {props.children} @@ -33,9 +30,9 @@ export function EmployeeTerminations(props: EmployeeTerminationsProps) { ) } -const Root = ({ employeeId, companyId, dictionary }: EmployeeTerminationsProps) => { - useComponentDictionary('Terminations.EmployeeTerminations', dictionary) - useI18n('Terminations.EmployeeTerminations') +const Root = ({ employeeId, companyId, dictionary }: TerminateEmployeeProps) => { + useComponentDictionary('Terminations.TerminateEmployee', dictionary) + useI18n('Terminations.TerminateEmployee') const queryClient = useQueryClient() const { onEvent, baseSubmitHandler } = useBase() @@ -174,7 +171,7 @@ const Root = ({ employeeId, companyId, dictionary }: EmployeeTerminationsProps) const isPending = isCreatingTermination || isCreatingPayroll return ( - void @@ -18,7 +18,7 @@ interface EmployeeTerminationsPresentationProps { payrollOptionError?: string } -export function EmployeeTerminationsPresentation({ +export function TerminateEmployeePresentation({ employeeName, lastDayOfWork, onLastDayOfWorkChange, @@ -29,10 +29,10 @@ export function EmployeeTerminationsPresentation({ isLoading, lastDayError, payrollOptionError, -}: EmployeeTerminationsPresentationProps) { +}: TerminateEmployeePresentationProps) { const { Heading, Text, DatePicker, RadioGroup, Button } = useComponentContext() - useI18n('Terminations.EmployeeTerminations') - const { t } = useTranslation('Terminations.EmployeeTerminations') + useI18n('Terminations.TerminateEmployee') + const { t } = useTranslation('Terminations.TerminateEmployee') const payrollOptions = [ { @@ -61,8 +61,8 @@ export function EmployeeTerminationsPresentation({ { onPayrollOptionChange(value as PayrollOption) diff --git a/src/components/Terminations/TerminationsData.tsx b/src/components/Terminations/TerminationsData.tsx index 61d14373..d678d91b 100644 --- a/src/components/Terminations/TerminationsData.tsx +++ b/src/components/Terminations/TerminationsData.tsx @@ -17,7 +17,7 @@ import type { Payroll } from '@gusto/embedded-api/models/components/payroll' import type { UnprocessedTerminationPayPeriod } from '@gusto/embedded-api/models/components/unprocessedterminationpayperiod' import type { PayrollPrepared } from '@gusto/embedded-api/models/components/payrollprepared' import type { ShowEmployees } from '@gusto/embedded-api/models/components/showemployees' -import { EmployeeTerminations } from './EmployeeTerminations' +import { TerminateEmployee } from './TerminateEmployee/TerminateEmployee' interface TerminationsDataProps { companyId: string @@ -826,7 +826,7 @@ function TerminationModal({
} > - Date: Wed, 31 Dec 2025 14:18:33 -0500 Subject: [PATCH 07/12] feat: add conditional alert for final payroll selections --- .../TerminateEmployee.test.tsx | 12 ++--------- .../TerminateEmployee/TerminateEmployee.tsx | 21 ++++--------------- .../TerminateEmployeePresentation.tsx | 14 ++++++------- .../en/Terminations.TerminateEmployee.json | 14 +++++++++++++ src/types/i18next.d.ts | 14 +++++++++++++ 5 files changed, 40 insertions(+), 35 deletions(-) diff --git a/src/components/Terminations/TerminateEmployee/TerminateEmployee.test.tsx b/src/components/Terminations/TerminateEmployee/TerminateEmployee.test.tsx index 324f3530..ede552fd 100644 --- a/src/components/Terminations/TerminateEmployee/TerminateEmployee.test.tsx +++ b/src/components/Terminations/TerminateEmployee/TerminateEmployee.test.tsx @@ -136,19 +136,11 @@ describe('TerminateEmployee', () => { }) }) - it('shows validation error when submitting without payroll option', async () => { + it('has dismissal payroll selected by default', async () => { renderWithProviders() await waitFor(() => { - expect(screen.getByRole('button', { name: 'Terminate employee' })).toBeInTheDocument() - }) - - await user.click(screen.getByRole('button', { name: 'Terminate employee' })) - - await waitFor(() => { - expect( - screen.getByText('Please select how to handle the final payroll'), - ).toBeInTheDocument() + expect(screen.getByLabelText('Dismissal payroll')).toBeChecked() }) }) }) diff --git a/src/components/Terminations/TerminateEmployee/TerminateEmployee.tsx b/src/components/Terminations/TerminateEmployee/TerminateEmployee.tsx index a15bc874..ab87f375 100644 --- a/src/components/Terminations/TerminateEmployee/TerminateEmployee.tsx +++ b/src/components/Terminations/TerminateEmployee/TerminateEmployee.tsx @@ -38,9 +38,8 @@ const Root = ({ employeeId, companyId, dictionary }: TerminateEmployeeProps) => const { onEvent, baseSubmitHandler } = useBase() const [lastDayOfWork, setLastDayOfWork] = useState(null) - const [payrollOption, setPayrollOption] = useState(null) + const [payrollOption, setPayrollOption] = useState('dismissalPayroll') const [lastDayError, setLastDayError] = useState() - const [payrollOptionError, setPayrollOptionError] = useState() const { data: { employee }, @@ -60,23 +59,12 @@ const Root = ({ employeeId, companyId, dictionary }: TerminateEmployeeProps) => const employeeName = [employee?.firstName, employee?.lastName].filter(Boolean).join(' ') const validateForm = (): boolean => { - let isValid = true - if (!lastDayOfWork) { setLastDayError('Last day of work is required') - isValid = false - } else { - setLastDayError(undefined) - } - - if (!payrollOption) { - setPayrollOptionError('Please select how to handle the final payroll') - isValid = false - } else { - setPayrollOptionError(undefined) + return false } - - return isValid + setLastDayError(undefined) + return true } const handleSubmit = async () => { @@ -181,7 +169,6 @@ const Root = ({ employeeId, companyId, dictionary }: TerminateEmployeeProps) => onCancel={handleCancel} isLoading={isPending} lastDayError={lastDayError} - payrollOptionError={payrollOptionError} /> ) } diff --git a/src/components/Terminations/TerminateEmployee/TerminateEmployeePresentation.tsx b/src/components/Terminations/TerminateEmployee/TerminateEmployeePresentation.tsx index d64fee3b..dbcc3258 100644 --- a/src/components/Terminations/TerminateEmployee/TerminateEmployeePresentation.tsx +++ b/src/components/Terminations/TerminateEmployee/TerminateEmployeePresentation.tsx @@ -9,13 +9,12 @@ interface TerminateEmployeePresentationProps { employeeName: string lastDayOfWork: Date | null onLastDayOfWorkChange: (date: Date | null) => void - payrollOption: PayrollOption | null + payrollOption: PayrollOption onPayrollOptionChange: (option: PayrollOption) => void onSubmit: () => void onCancel: () => void isLoading: boolean lastDayError?: string - payrollOptionError?: string } export function TerminateEmployeePresentation({ @@ -28,9 +27,8 @@ export function TerminateEmployeePresentation({ onCancel, isLoading, lastDayError, - payrollOptionError, }: TerminateEmployeePresentationProps) { - const { Heading, Text, DatePicker, RadioGroup, Button } = useComponentContext() + const { Alert, Heading, Text, DatePicker, RadioGroup, Button } = useComponentContext() useI18n('Terminations.TerminateEmployee') const { t } = useTranslation('Terminations.TerminateEmployee') @@ -73,15 +71,15 @@ export function TerminateEmployeePresentation({ { onPayrollOptionChange(value as PayrollOption) }} - isRequired - errorMessage={payrollOptionError} - isInvalid={!!payrollOptionError} options={payrollOptions} /> + + {t(`alert.${payrollOption}.text`, { employeeName })} + diff --git a/src/i18n/en/Terminations.TerminateEmployee.json b/src/i18n/en/Terminations.TerminateEmployee.json index f11e0e8f..a9f2d76d 100644 --- a/src/i18n/en/Terminations.TerminateEmployee.json +++ b/src/i18n/en/Terminations.TerminateEmployee.json @@ -25,6 +25,20 @@ } } }, + "alert": { + "dismissalPayroll": { + "label": "After submitting, you won't be able to undo this dismissal", + "text": "Make sure you want to end {{employeeName}}'s employment. You won't be able to cancel this dismissal and you'll need to rehire them if they return." + }, + "regularPayroll": { + "label": "After their last day, you won't be able to undo this dismissal", + "text": "Make sure you want to end {{employeeName}}'s employment. You will be able to cancel the dismissal or make changes until their last day. After their last working day, you won't be able to cancel this dismissal and you'll need to rehire them if they return." + }, + "anotherWay": { + "label": "After their last day, you won't be able to undo this dismissal", + "text": "Make sure you want to end {{employeeName}}'s employment. You will be able to cancel the dismissal or make changes until their last day. After their last working day, you won't be able to cancel this dismissal and you'll need to rehire them if they return." + } + }, "actions": { "submit": "Terminate employee", "cancel": "Cancel" diff --git a/src/types/i18next.d.ts b/src/types/i18next.d.ts index a445835b..40cbd8f3 100644 --- a/src/types/i18next.d.ts +++ b/src/types/i18next.d.ts @@ -1708,6 +1708,20 @@ export interface TerminationsTerminateEmployee{ }; }; }; +"alert":{ +"dismissalPayroll":{ +"label":string; +"text":string; +}; +"regularPayroll":{ +"label":string; +"text":string; +}; +"anotherWay":{ +"label":string; +"text":string; +}; +}; "actions":{ "submit":string; "cancel":string; From cba45c53b18d8157ded548375dbec12af3ca99a0 Mon Sep 17 00:00:00 2001 From: Aaron Lee Date: Wed, 31 Dec 2025 14:26:51 -0500 Subject: [PATCH 08/12] fix: update translation keys, change subtilte to supporting text variant --- .../TerminateEmployee/TerminateEmployeePresentation.tsx | 4 ++-- src/i18n/en/Terminations.TerminateEmployee.json | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/components/Terminations/TerminateEmployee/TerminateEmployeePresentation.tsx b/src/components/Terminations/TerminateEmployee/TerminateEmployeePresentation.tsx index dbcc3258..7a8ff7f3 100644 --- a/src/components/Terminations/TerminateEmployee/TerminateEmployeePresentation.tsx +++ b/src/components/Terminations/TerminateEmployee/TerminateEmployeePresentation.tsx @@ -52,9 +52,9 @@ export function TerminateEmployeePresentation({ return ( - + {t('title', { employeeName })} - {t('subtitle')} + {t('subtitle')} diff --git a/src/i18n/en/Terminations.TerminateEmployee.json b/src/i18n/en/Terminations.TerminateEmployee.json index a9f2d76d..17e040ed 100644 --- a/src/i18n/en/Terminations.TerminateEmployee.json +++ b/src/i18n/en/Terminations.TerminateEmployee.json @@ -12,15 +12,15 @@ "options": { "dismissalPayroll": { "label": "Dismissal payroll", - "description": "Run an off-cycle payroll to pay the employee their final wages immediately. This is required in some states." + "description": "Runs a final payroll that automatically pays out unused PTO, lets you include severance, keep a separate record for audits, and choose a custom payday based on when you run it." }, "regularPayroll": { "label": "Regular payroll", - "description": "The employee will receive their final wages on their current pay schedule." + "description": "Same as dismissal payrolls, except there won’t be a separate record of final payment and you can’t customize the final payday (they’ll be paid on the regular payday)." }, "anotherWay": { "label": "Another way", - "description": "Handle the final payment manually (e.g., paper check). No payroll will be created." + "description": "You can run an off-cycle payroll to manually calculate final amounts, or you can pay them outside of the app (but make sure to report this payroll so the amounts are recorded on tax forms). You can also select this option if you’ve already paid them." } } } From e812d11df7674ee9a67c664a80d249dbf8cc7212 Mon Sep 17 00:00:00 2001 From: Aaron Lee Date: Wed, 31 Dec 2025 16:29:01 -0500 Subject: [PATCH 09/12] feat: add termination summary --- .../DescriptionList.module.scss | 10 + .../TerminateEmployee.test.tsx | 12 +- .../TerminationSummary.test.tsx | 267 ++++++++++++++++++ .../TerminationSummary/TerminationSummary.tsx | 127 +++++++++ .../TerminationSummaryPresentation.tsx | 90 ++++++ .../Terminations/TerminationsData.tsx | 129 ++++++++- src/components/Terminations/index.tsx | 1 + .../en/Terminations.TerminationSummary.json | 19 ++ src/shared/constants.ts | 4 + src/types/i18next.d.ts | 21 +- 10 files changed, 671 insertions(+), 9 deletions(-) create mode 100644 src/components/Terminations/TerminationSummary/TerminationSummary.test.tsx create mode 100644 src/components/Terminations/TerminationSummary/TerminationSummary.tsx create mode 100644 src/components/Terminations/TerminationSummary/TerminationSummaryPresentation.tsx create mode 100644 src/i18n/en/Terminations.TerminationSummary.json diff --git a/src/components/Common/UI/DescriptionList/DescriptionList.module.scss b/src/components/Common/UI/DescriptionList/DescriptionList.module.scss index df8f59da..8e1fb35f 100644 --- a/src/components/Common/UI/DescriptionList/DescriptionList.module.scss +++ b/src/components/Common/UI/DescriptionList/DescriptionList.module.scss @@ -1,6 +1,16 @@ .root { width: 100%; + dt { + font-weight: var(--g-fontWeightMedium); + color: var(--g-colorBodyContent); + } + + dd { + font-weight: var(--g-fontWeightRegular); + color: var(--g-colorBodySubContent); + } + .item { &:not(:last-child) { padding-bottom: toRem(20); diff --git a/src/components/Terminations/TerminateEmployee/TerminateEmployee.test.tsx b/src/components/Terminations/TerminateEmployee/TerminateEmployee.test.tsx index ede552fd..e4aeabe1 100644 --- a/src/components/Terminations/TerminateEmployee/TerminateEmployee.test.tsx +++ b/src/components/Terminations/TerminateEmployee/TerminateEmployee.test.tsx @@ -204,19 +204,17 @@ describe('TerminateEmployee', () => { await waitFor(() => { expect( - screen.getByText( - /Run an off-cycle payroll to pay the employee their final wages immediately/, - ), + screen.getByText(/Runs a final payroll that automatically pays out unused PTO/), ).toBeInTheDocument() }) expect( - screen.getByText( - /The employee will receive their final wages on their current pay schedule/, - ), + screen.getByText(/Same as dismissal payrolls, except there won.t be a separate record/), ).toBeInTheDocument() - expect(screen.getByText(/Handle the final payment manually/)).toBeInTheDocument() + expect( + screen.getByText(/You can run an off-cycle payroll to manually calculate/), + ).toBeInTheDocument() }) }) }) diff --git a/src/components/Terminations/TerminationSummary/TerminationSummary.test.tsx b/src/components/Terminations/TerminationSummary/TerminationSummary.test.tsx new file mode 100644 index 00000000..56c85bca --- /dev/null +++ b/src/components/Terminations/TerminationSummary/TerminationSummary.test.tsx @@ -0,0 +1,267 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { http, HttpResponse } from 'msw' +import { TerminationSummary } from './TerminationSummary' +import { server } from '@/test/mocks/server' +import { componentEvents } from '@/shared/constants' +import { setupApiTestMocks } from '@/test/mocks/apiServer' +import { renderWithProviders } from '@/test-utils/renderWithProviders' +import { API_BASE_URL } from '@/test/constants' + +const mockEmployee = { + uuid: 'employee-123', + first_name: 'John', + last_name: 'Doe', + email: 'john.doe@example.com', + company_uuid: 'company-123', + terminated: true, + onboarded: true, +} + +const getFutureDate = () => { + const date = new Date() + date.setDate(date.getDate() + 7) + return date.toISOString().split('T')[0] +} + +const getPastDate = () => { + const date = new Date() + date.setDate(date.getDate() - 7) + return date.toISOString().split('T')[0] +} + +const mockTerminationCancelable = { + uuid: 'termination-123', + employee_uuid: 'employee-123', + effective_date: getFutureDate(), + run_termination_payroll: false, + active: true, + cancelable: true, +} + +const mockTerminationWithPayroll = { + uuid: 'termination-456', + employee_uuid: 'employee-123', + effective_date: getFutureDate(), + run_termination_payroll: true, + active: true, + cancelable: false, +} + +const mockTerminationPast = { + uuid: 'termination-789', + employee_uuid: 'employee-123', + effective_date: getPastDate(), + run_termination_payroll: false, + active: true, + cancelable: false, +} + +describe('TerminationSummary', () => { + const onEvent = vi.fn() + const user = userEvent.setup() + const defaultProps = { + employeeId: 'employee-123', + companyId: 'company-123', + onEvent, + } + + beforeEach(() => { + setupApiTestMocks() + onEvent.mockClear() + + server.use( + http.get(`${API_BASE_URL}/v1/employees/:employee_id`, () => { + return HttpResponse.json(mockEmployee) + }), + ) + }) + + describe('rendering', () => { + it('renders success alert with employee name', async () => { + server.use( + http.get(`${API_BASE_URL}/v1/employees/:employee_id/terminations`, () => { + return HttpResponse.json([mockTerminationCancelable]) + }), + ) + + renderWithProviders() + + await waitFor(() => { + expect(screen.getByText('John Doe has been successfully terminated')).toBeInTheDocument() + }) + }) + + it('displays termination dates correctly', async () => { + server.use( + http.get(`${API_BASE_URL}/v1/employees/:employee_id/terminations`, () => { + return HttpResponse.json([mockTerminationCancelable]) + }), + ) + + renderWithProviders() + + await waitFor(() => { + expect(screen.getByText('Last day of work')).toBeInTheDocument() + }) + + expect(screen.getByText('Last pay day')).toBeInTheDocument() + }) + }) + + describe('conditional buttons', () => { + it('shows cancel button when termination is cancelable', async () => { + server.use( + http.get(`${API_BASE_URL}/v1/employees/:employee_id/terminations`, () => { + return HttpResponse.json([mockTerminationCancelable]) + }), + ) + + renderWithProviders() + + await waitFor(() => { + expect(screen.getByRole('button', { name: 'Cancel termination' })).toBeInTheDocument() + }) + }) + + it('shows edit button when effective date is in the future', async () => { + server.use( + http.get(`${API_BASE_URL}/v1/employees/:employee_id/terminations`, () => { + return HttpResponse.json([mockTerminationCancelable]) + }), + ) + + renderWithProviders() + + await waitFor(() => { + expect(screen.getByRole('button', { name: 'Edit dismissal' })).toBeInTheDocument() + }) + }) + + it('hides edit button when effective date is in the past', async () => { + server.use( + http.get(`${API_BASE_URL}/v1/employees/:employee_id/terminations`, () => { + return HttpResponse.json([mockTerminationPast]) + }), + ) + + renderWithProviders() + + await waitFor(() => { + expect(screen.getByText('John Doe has been successfully terminated')).toBeInTheDocument() + }) + + expect(screen.queryByRole('button', { name: 'Edit dismissal' })).not.toBeInTheDocument() + }) + + it('shows run payroll button when dismissal payroll was selected', async () => { + server.use( + http.get(`${API_BASE_URL}/v1/employees/:employee_id/terminations`, () => { + return HttpResponse.json([mockTerminationWithPayroll]) + }), + ) + + renderWithProviders() + + await waitFor(() => { + expect(screen.getByRole('button', { name: 'Run dismissal payroll' })).toBeInTheDocument() + }) + }) + + it('hides run payroll button when dismissal payroll was not selected', async () => { + server.use( + http.get(`${API_BASE_URL}/v1/employees/:employee_id/terminations`, () => { + return HttpResponse.json([mockTerminationCancelable]) + }), + ) + + renderWithProviders() + + await waitFor(() => { + expect(screen.getByText('John Doe has been successfully terminated')).toBeInTheDocument() + }) + + expect( + screen.queryByRole('button', { name: 'Run dismissal payroll' }), + ).not.toBeInTheDocument() + }) + }) + + describe('actions', () => { + it('emits EMPLOYEE_TERMINATION_CANCELLED event when cancel button is clicked', async () => { + server.use( + http.get(`${API_BASE_URL}/v1/employees/:employee_id/terminations`, () => { + return HttpResponse.json([mockTerminationCancelable]) + }), + http.delete(`${API_BASE_URL}/v1/employees/:employee_id/terminations`, () => { + return new HttpResponse(null, { status: 204 }) + }), + ) + + renderWithProviders() + + await waitFor(() => { + expect(screen.getByRole('button', { name: 'Cancel termination' })).toBeInTheDocument() + }) + + await user.click(screen.getByRole('button', { name: 'Cancel termination' })) + + await waitFor(() => { + expect(onEvent).toHaveBeenCalledWith( + componentEvents.EMPLOYEE_TERMINATION_CANCELLED, + expect.objectContaining({ + employeeId: 'employee-123', + }), + ) + }) + }) + + it('emits EMPLOYEE_TERMINATION_EDIT event when edit button is clicked', async () => { + server.use( + http.get(`${API_BASE_URL}/v1/employees/:employee_id/terminations`, () => { + return HttpResponse.json([mockTerminationCancelable]) + }), + ) + + renderWithProviders() + + await waitFor(() => { + expect(screen.getByRole('button', { name: 'Edit dismissal' })).toBeInTheDocument() + }) + + await user.click(screen.getByRole('button', { name: 'Edit dismissal' })) + + expect(onEvent).toHaveBeenCalledWith( + componentEvents.EMPLOYEE_TERMINATION_EDIT, + expect.objectContaining({ + employeeId: 'employee-123', + }), + ) + }) + + it('emits EMPLOYEE_TERMINATION_RUN_PAYROLL event when run payroll button is clicked', async () => { + server.use( + http.get(`${API_BASE_URL}/v1/employees/:employee_id/terminations`, () => { + return HttpResponse.json([mockTerminationWithPayroll]) + }), + ) + + renderWithProviders() + + await waitFor(() => { + expect(screen.getByRole('button', { name: 'Run dismissal payroll' })).toBeInTheDocument() + }) + + await user.click(screen.getByRole('button', { name: 'Run dismissal payroll' })) + + expect(onEvent).toHaveBeenCalledWith( + componentEvents.EMPLOYEE_TERMINATION_RUN_PAYROLL, + expect.objectContaining({ + employeeId: 'employee-123', + companyId: 'company-123', + }), + ) + }) + }) +}) diff --git a/src/components/Terminations/TerminationSummary/TerminationSummary.tsx b/src/components/Terminations/TerminationSummary/TerminationSummary.tsx new file mode 100644 index 00000000..140db2de --- /dev/null +++ b/src/components/Terminations/TerminationSummary/TerminationSummary.tsx @@ -0,0 +1,127 @@ +import { useQueryClient } from '@tanstack/react-query' +import { useEmployeesGetSuspense } from '@gusto/embedded-api/react-query/employeesGet' +import { + useEmployeeEmploymentsGetTerminationsSuspense, + invalidateAllEmployeeEmploymentsGetTerminations, +} from '@gusto/embedded-api/react-query/employeeEmploymentsGetTerminations' +import { useEmployeeEmploymentsDeleteTerminationMutation } from '@gusto/embedded-api/react-query/employeeEmploymentsDeleteTermination' +import { invalidateAllEmployeesList } from '@gusto/embedded-api/react-query/employeesList' +import { TerminationSummaryPresentation } from './TerminationSummaryPresentation' +import { normalizeToDate } from '@/helpers/dateFormatting' +import type { BaseComponentInterface } from '@/components/Base/Base' +import { BaseComponent } from '@/components/Base/Base' +import { useBase } from '@/components/Base/useBase' +import { componentEvents } from '@/shared/constants' +import { useComponentDictionary, useI18n } from '@/i18n' + +export type PayrollOption = 'dismissalPayroll' | 'regularPayroll' | 'anotherWay' + +export interface TerminationSummaryProps extends BaseComponentInterface<'Terminations.TerminationSummary'> { + employeeId: string + companyId: string + payrollOption?: PayrollOption +} + +export function TerminationSummary(props: TerminationSummaryProps) { + return ( + + {props.children} + + ) +} + +const Root = ({ employeeId, companyId, payrollOption, dictionary }: TerminationSummaryProps) => { + useComponentDictionary('Terminations.TerminationSummary', dictionary) + useI18n('Terminations.TerminationSummary') + + const queryClient = useQueryClient() + const { onEvent, baseSubmitHandler } = useBase() + + const { + data: { employee }, + } = useEmployeesGetSuspense({ employeeId }) + + const { data: terminationsData } = useEmployeeEmploymentsGetTerminationsSuspense({ employeeId }) + + const { mutateAsync: deleteTermination, isPending: isDeleting } = + useEmployeeEmploymentsDeleteTerminationMutation() + + const employeeName = [employee?.firstName, employee?.lastName].filter(Boolean).join(' ') + + const terminations = terminationsData.terminationList ?? [] + const termination = terminations.find(t => t.active) ?? terminations[0] + + const effectiveDate = termination?.effectiveDate + const canCancel = termination?.cancelable === true + const effectiveDateLocal = normalizeToDate(effectiveDate) + const todayMidnight = new Date(new Date().toDateString()) + const canEdit = effectiveDateLocal ? effectiveDateLocal >= todayMidnight : false + + const showRunOffCyclePayroll = payrollOption === 'anotherWay' + const showRunPayroll = + !showRunOffCyclePayroll && + (termination?.runTerminationPayroll === true || payrollOption === 'dismissalPayroll') + + const handleCancelTermination = async () => { + if (!termination) return + + await baseSubmitHandler({ terminationId: termination.uuid }, async () => { + await deleteTermination({ + request: { + employeeId, + }, + }) + + await invalidateAllEmployeeEmploymentsGetTerminations(queryClient) + await invalidateAllEmployeesList(queryClient) + + onEvent(componentEvents.EMPLOYEE_TERMINATION_CANCELLED, { + employeeId, + termination, + }) + }) + } + + const handleEditDismissal = () => { + onEvent(componentEvents.EMPLOYEE_TERMINATION_EDIT, { + employeeId, + termination, + }) + } + + const handleRunDismissalPayroll = () => { + onEvent(componentEvents.EMPLOYEE_TERMINATION_RUN_PAYROLL, { + employeeId, + companyId, + termination, + }) + } + + const handleRunOffCyclePayroll = () => { + onEvent(componentEvents.EMPLOYEE_TERMINATION_RUN_OFF_CYCLE_PAYROLL, { + employeeId, + companyId, + termination, + }) + } + + if (!termination) { + return null + } + + return ( + + ) +} diff --git a/src/components/Terminations/TerminationSummary/TerminationSummaryPresentation.tsx b/src/components/Terminations/TerminationSummary/TerminationSummaryPresentation.tsx new file mode 100644 index 00000000..ee85b40c --- /dev/null +++ b/src/components/Terminations/TerminationSummary/TerminationSummaryPresentation.tsx @@ -0,0 +1,90 @@ +import { useTranslation } from 'react-i18next' +import { ActionsLayout, Flex } from '@/components/Common' +import { useComponentContext } from '@/contexts/ComponentAdapter/useComponentContext' +import { useDateFormatter } from '@/hooks/useDateFormatter' +import { useI18n } from '@/i18n' + +interface TerminationSummaryPresentationProps { + employeeName: string + effectiveDate: string | undefined + canCancel: boolean + canEdit: boolean + showRunPayroll: boolean + showRunOffCyclePayroll: boolean + onCancelTermination: () => void + onEditDismissal: () => void + onRunDismissalPayroll: () => void + onRunOffCyclePayroll: () => void + isLoading: boolean +} + +export function TerminationSummaryPresentation({ + employeeName, + effectiveDate, + canCancel, + canEdit, + showRunPayroll, + showRunOffCyclePayroll, + onCancelTermination, + onEditDismissal, + onRunDismissalPayroll, + onRunOffCyclePayroll, + isLoading, +}: TerminationSummaryPresentationProps) { + const { Alert, Heading, Text, Button, DescriptionList } = useComponentContext() + const { formatLongWithYear } = useDateFormatter() + useI18n('Terminations.TerminationSummary') + const { t } = useTranslation('Terminations.TerminationSummary') + + const formattedDate = formatLongWithYear(effectiveDate) || 'N/A' + + const dateItems = [ + { + term: t('dates.lastDayOfWork'), + description: formattedDate, + }, + { + term: t('dates.lastPayDay'), + description: formattedDate, + }, + ] + + const hasActions = canCancel || canEdit || showRunPayroll || showRunOffCyclePayroll + + return ( + + + + {t('title')} + {t('subtitle')} + + + + + {hasActions && ( + + {canCancel && ( + + )} + {canEdit && ( + + )} + {showRunPayroll && ( + + )} + {showRunOffCyclePayroll && ( + + )} + + )} + + ) +} diff --git a/src/components/Terminations/TerminationsData.tsx b/src/components/Terminations/TerminationsData.tsx index d678d91b..1a8d4ec4 100644 --- a/src/components/Terminations/TerminationsData.tsx +++ b/src/components/Terminations/TerminationsData.tsx @@ -18,6 +18,7 @@ import type { UnprocessedTerminationPayPeriod } from '@gusto/embedded-api/models import type { PayrollPrepared } from '@gusto/embedded-api/models/components/payrollprepared' import type { ShowEmployees } from '@gusto/embedded-api/models/components/showemployees' import { TerminateEmployee } from './TerminateEmployee/TerminateEmployee' +import { TerminationSummary } from './TerminationSummary/TerminationSummary' interface TerminationsDataProps { companyId: string @@ -628,9 +629,11 @@ function TerminationPayPeriodsTable({ function TerminatedEmployeesTable({ employees, isMockData, + onViewSummary, }: { employees: ShowEmployees[] isMockData?: boolean + onViewSummary: (employeeId: string) => void }) { if (employees.length === 0) { return

No terminated employees found.

@@ -647,6 +650,7 @@ function TerminatedEmployeesTable({ Cancelable Employee ID {isMockData && Source} + Actions @@ -696,6 +700,26 @@ function TerminatedEmployeesTable({
)} + + + ) })} @@ -845,10 +869,100 @@ function TerminationModal({ ) } +interface TerminationSummaryModalProps { + isOpen: boolean + onClose: () => void + companyId: string + employeeId: string | null +} + +function TerminationSummaryModal({ + isOpen, + onClose, + companyId, + employeeId, +}: TerminationSummaryModalProps) { + if (!isOpen || !employeeId) return null + + const handleEvent = (event: string) => { + if ( + event === 'employee/termination/cancelled' || + event === 'employee/termination/edit' || + event === 'employee/termination/runPayroll' || + event === 'employee/termination/runOffCyclePayroll' + ) { + onClose() + } + } + + return ( +
+ +
+ + + Loading termination summary... + + } + > + + + + + ) +} + function TerminationsDataContent({ companyId, useMockData }: TerminationsDataProps) { const queryClient = useQueryClient() const [isModalOpen, setIsModalOpen] = useState(false) const [showMockData, setShowMockData] = useState(useMockData ?? false) + const [summaryEmployeeId, setSummaryEmployeeId] = useState(null) const today = new Date() const threeMonthsAgo = new Date(today.getFullYear(), today.getMonth() - 3, today.getDate()) @@ -1101,7 +1215,11 @@ function TerminationsDataContent({ companyId, useMockData }: TerminationsDataPro

All employees who have been terminated or are scheduled to be terminated.

- + + + { + setSummaryEmployeeId(null) + }} + companyId={companyId} + employeeId={summaryEmployeeId} + /> ) } diff --git a/src/components/Terminations/index.tsx b/src/components/Terminations/index.tsx index 4d25fca5..dd8ce7cc 100644 --- a/src/components/Terminations/index.tsx +++ b/src/components/Terminations/index.tsx @@ -1,2 +1,3 @@ export { TerminateEmployee } from './TerminateEmployee/TerminateEmployee' +export { TerminationSummary } from './TerminationSummary/TerminationSummary' export { TerminationsData } from './TerminationsData' diff --git a/src/i18n/en/Terminations.TerminationSummary.json b/src/i18n/en/Terminations.TerminationSummary.json new file mode 100644 index 00000000..1d0edeee --- /dev/null +++ b/src/i18n/en/Terminations.TerminationSummary.json @@ -0,0 +1,19 @@ +{ + "title": "Termination summary", + "subtitle": "The termination has been submitted. Here's the timeline and what to expect.", + "alert": { + "success": { + "label": "{{employeeName}} has been successfully terminated" + } + }, + "dates": { + "lastDayOfWork": "Last day of work", + "lastPayDay": "Last pay day" + }, + "actions": { + "cancelTermination": "Cancel termination", + "editDismissal": "Edit termination", + "runDismissalPayroll": "Run termination payroll", + "runOffCyclePayroll": "Run off-cycle payroll" + } +} diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 5b2528de..c429a2fb 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -126,6 +126,10 @@ export const terminationEvents = { EMPLOYEE_TERMINATION_PAYROLL_CREATED: 'employee/termination/payroll/created', EMPLOYEE_TERMINATION_PAYROLL_FAILED: 'employee/termination/payroll/failed', EMPLOYEE_TERMINATION_DONE: 'employee/termination/done', + EMPLOYEE_TERMINATION_CANCELLED: 'employee/termination/cancelled', + EMPLOYEE_TERMINATION_EDIT: 'employee/termination/edit', + EMPLOYEE_TERMINATION_RUN_PAYROLL: 'employee/termination/runPayroll', + EMPLOYEE_TERMINATION_RUN_OFF_CYCLE_PAYROLL: 'employee/termination/runOffCyclePayroll', } as const export const payScheduleEvents = { diff --git a/src/types/i18next.d.ts b/src/types/i18next.d.ts index 40cbd8f3..7d8d0826 100644 --- a/src/types/i18next.d.ts +++ b/src/types/i18next.d.ts @@ -1731,6 +1731,25 @@ export interface TerminationsTerminateEmployee{ "payrollOptionRequired":string; }; }; +export interface TerminationsTerminationSummary{ +"title":string; +"subtitle":string; +"alert":{ +"success":{ +"label":string; +}; +}; +"dates":{ +"lastDayOfWork":string; +"lastPayDay":string; +}; +"actions":{ +"cancelTermination":string; +"editDismissal":string; +"runDismissalPayroll":string; +"runOffCyclePayroll":string; +}; +}; export interface common{ "status":{ "loading":string; @@ -1905,6 +1924,6 @@ export interface common{ interface CustomTypeOptions { defaultNS: 'common'; - resources: { 'Company.Addresses': CompanyAddresses, 'Company.AssignSignatory': CompanyAssignSignatory, 'Company.BankAccount': CompanyBankAccount, 'Company.DocumentList': CompanyDocumentList, 'Company.FederalTaxes': CompanyFederalTaxes, 'Company.Industry': CompanyIndustry, 'Company.Locations': CompanyLocations, 'Company.OnboardingOverview': CompanyOnboardingOverview, 'Company.PaySchedule': CompanyPaySchedule, 'Company.SignatureForm': CompanySignatureForm, 'Company.StateTaxes': CompanyStateTaxes, 'Contractor.Address': ContractorAddress, 'Contractor.ContractorList': ContractorContractorList, 'Contractor.NewHireReport': ContractorNewHireReport, 'Contractor.PaymentMethod': ContractorPaymentMethod, 'Contractor.Payments.CreatePayment': ContractorPaymentsCreatePayment, 'Contractor.Payments.PaymentHistory': ContractorPaymentsPaymentHistory, 'Contractor.Payments.PaymentsList': ContractorPaymentsPaymentsList, 'Contractor.Profile': ContractorProfile, 'Contractor.Submit': ContractorSubmit, 'Employee.BankAccount': EmployeeBankAccount, 'Employee.Compensation': EmployeeCompensation, 'Employee.Deductions': EmployeeDeductions, 'Employee.DocumentSigner': EmployeeDocumentSigner, 'Employee.EmployeeList': EmployeeEmployeeList, 'Employee.FederalTaxes': EmployeeFederalTaxes, 'Employee.HomeAddress': EmployeeHomeAddress, 'Employee.Landing': EmployeeLanding, 'Employee.OnboardingSummary': EmployeeOnboardingSummary, 'Employee.PaySchedules': EmployeePaySchedules, 'Employee.PaymentMethod': EmployeePaymentMethod, 'Employee.Profile': EmployeeProfile, 'Employee.SplitPaycheck': EmployeeSplitPaycheck, 'Employee.StateTaxes': EmployeeStateTaxes, 'Employee.Taxes': EmployeeTaxes, 'Payroll.Common': PayrollCommon, 'Payroll.ConfirmWireDetailsBanner': PayrollConfirmWireDetailsBanner, 'Payroll.ConfirmWireDetailsForm': PayrollConfirmWireDetailsForm, 'Payroll.PayrollBlocker': PayrollPayrollBlocker, 'Payroll.PayrollConfiguration': PayrollPayrollConfiguration, 'Payroll.PayrollEditEmployee': PayrollPayrollEditEmployee, 'Payroll.PayrollFlow': PayrollPayrollFlow, 'Payroll.PayrollHistory': PayrollPayrollHistory, 'Payroll.PayrollLanding': PayrollPayrollLanding, 'Payroll.PayrollList': PayrollPayrollList, 'Payroll.PayrollOverview': PayrollPayrollOverview, 'Payroll.PayrollReceipts': PayrollPayrollReceipts, 'Payroll.WireInstructions': PayrollWireInstructions, 'Terminations.TerminateEmployee': TerminationsTerminateEmployee, 'common': common, } + resources: { 'Company.Addresses': CompanyAddresses, 'Company.AssignSignatory': CompanyAssignSignatory, 'Company.BankAccount': CompanyBankAccount, 'Company.DocumentList': CompanyDocumentList, 'Company.FederalTaxes': CompanyFederalTaxes, 'Company.Industry': CompanyIndustry, 'Company.Locations': CompanyLocations, 'Company.OnboardingOverview': CompanyOnboardingOverview, 'Company.PaySchedule': CompanyPaySchedule, 'Company.SignatureForm': CompanySignatureForm, 'Company.StateTaxes': CompanyStateTaxes, 'Contractor.Address': ContractorAddress, 'Contractor.ContractorList': ContractorContractorList, 'Contractor.NewHireReport': ContractorNewHireReport, 'Contractor.PaymentMethod': ContractorPaymentMethod, 'Contractor.Payments.CreatePayment': ContractorPaymentsCreatePayment, 'Contractor.Payments.PaymentHistory': ContractorPaymentsPaymentHistory, 'Contractor.Payments.PaymentsList': ContractorPaymentsPaymentsList, 'Contractor.Profile': ContractorProfile, 'Contractor.Submit': ContractorSubmit, 'Employee.BankAccount': EmployeeBankAccount, 'Employee.Compensation': EmployeeCompensation, 'Employee.Deductions': EmployeeDeductions, 'Employee.DocumentSigner': EmployeeDocumentSigner, 'Employee.EmployeeList': EmployeeEmployeeList, 'Employee.FederalTaxes': EmployeeFederalTaxes, 'Employee.HomeAddress': EmployeeHomeAddress, 'Employee.Landing': EmployeeLanding, 'Employee.OnboardingSummary': EmployeeOnboardingSummary, 'Employee.PaySchedules': EmployeePaySchedules, 'Employee.PaymentMethod': EmployeePaymentMethod, 'Employee.Profile': EmployeeProfile, 'Employee.SplitPaycheck': EmployeeSplitPaycheck, 'Employee.StateTaxes': EmployeeStateTaxes, 'Employee.Taxes': EmployeeTaxes, 'Payroll.Common': PayrollCommon, 'Payroll.ConfirmWireDetailsBanner': PayrollConfirmWireDetailsBanner, 'Payroll.ConfirmWireDetailsForm': PayrollConfirmWireDetailsForm, 'Payroll.PayrollBlocker': PayrollPayrollBlocker, 'Payroll.PayrollConfiguration': PayrollPayrollConfiguration, 'Payroll.PayrollEditEmployee': PayrollPayrollEditEmployee, 'Payroll.PayrollFlow': PayrollPayrollFlow, 'Payroll.PayrollHistory': PayrollPayrollHistory, 'Payroll.PayrollLanding': PayrollPayrollLanding, 'Payroll.PayrollList': PayrollPayrollList, 'Payroll.PayrollOverview': PayrollPayrollOverview, 'Payroll.PayrollReceipts': PayrollPayrollReceipts, 'Payroll.WireInstructions': PayrollWireInstructions, 'Terminations.TerminateEmployee': TerminationsTerminateEmployee, 'Terminations.TerminationSummary': TerminationsTerminationSummary, 'common': common, } }; } \ No newline at end of file From e8019a18a1d4d25af7707bdf7b2be2c847862e67 Mon Sep 17 00:00:00 2001 From: Aaron Lee Date: Wed, 31 Dec 2025 16:57:35 -0500 Subject: [PATCH 10/12] feat: add termination flow --- .../TerminationFlow/TerminationFlow.tsx | 27 ++++++ .../TerminationFlowComponents.tsx | 81 +++++++++++++++++ .../Terminations/TerminationFlow/index.ts | 2 + .../terminationStateMachine.ts | 90 +++++++++++++++++++ .../TerminationSummary/TerminationSummary.tsx | 21 ++++- .../TerminationSummaryPresentation.tsx | 29 +++++- src/components/Terminations/index.tsx | 1 + src/i18n/en/Terminations.TerminationFlow.json | 3 + .../en/Terminations.TerminationSummary.json | 6 ++ src/types/i18next.d.ts | 11 ++- 10 files changed, 264 insertions(+), 7 deletions(-) create mode 100644 src/components/Terminations/TerminationFlow/TerminationFlow.tsx create mode 100644 src/components/Terminations/TerminationFlow/TerminationFlowComponents.tsx create mode 100644 src/components/Terminations/TerminationFlow/index.ts create mode 100644 src/components/Terminations/TerminationFlow/terminationStateMachine.ts create mode 100644 src/i18n/en/Terminations.TerminationFlow.json diff --git a/src/components/Terminations/TerminationFlow/TerminationFlow.tsx b/src/components/Terminations/TerminationFlow/TerminationFlow.tsx new file mode 100644 index 00000000..eb1845e2 --- /dev/null +++ b/src/components/Terminations/TerminationFlow/TerminationFlow.tsx @@ -0,0 +1,27 @@ +import { createMachine } from 'robot3' +import { useMemo } from 'react' +import { terminationMachine } from './terminationStateMachine' +import type { + TerminationFlowProps, + TerminationFlowContextInterface, +} from './TerminationFlowComponents' +import { TerminateEmployeeContextual } from './TerminationFlowComponents' +import { Flow } from '@/components/Flow/Flow' + +export const TerminationFlow = ({ companyId, employeeId, onEvent }: TerminationFlowProps) => { + const terminationFlow = useMemo( + () => + createMachine( + 'form', + terminationMachine, + (initialContext: TerminationFlowContextInterface) => ({ + ...initialContext, + component: TerminateEmployeeContextual, + companyId, + employeeId, + }), + ), + [companyId, employeeId], + ) + return +} diff --git a/src/components/Terminations/TerminationFlow/TerminationFlowComponents.tsx b/src/components/Terminations/TerminationFlow/TerminationFlowComponents.tsx new file mode 100644 index 00000000..a66eeb37 --- /dev/null +++ b/src/components/Terminations/TerminationFlow/TerminationFlowComponents.tsx @@ -0,0 +1,81 @@ +import type { ReactNode } from 'react' +import { useTranslation } from 'react-i18next' +import { TerminateEmployee } from '../TerminateEmployee/TerminateEmployee' +import { TerminationSummary } from '../TerminationSummary/TerminationSummary' +import type { PayrollOption } from '../TerminateEmployee/TerminateEmployeePresentation' +import { useFlow, type FlowContextInterface } from '@/components/Flow/useFlow' +import type { BaseComponentInterface } from '@/components/Base' +import { Flex } from '@/components/Common' +import { ensureRequired } from '@/helpers/ensureRequired' +import { useComponentContext } from '@/contexts/ComponentAdapter/useComponentContext' +import { useI18n } from '@/i18n' +import { componentEvents, type EventType } from '@/shared/constants' + +export interface TerminationFlowProps extends BaseComponentInterface { + companyId: string + employeeId: string +} + +export type TerminationFlowAlert = { + type: 'error' | 'info' | 'success' + title: string + content?: ReactNode +} + +export interface TerminationFlowContextInterface extends FlowContextInterface { + companyId: string + employeeId: string + payrollOption?: PayrollOption + alerts?: TerminationFlowAlert[] +} + +export function TerminateEmployeeContextual() { + const { companyId, employeeId, onEvent, alerts } = useFlow() + const { Alert } = useComponentContext() + useI18n('Terminations.TerminationFlow') + + return ( + + {alerts?.map((alert, index) => ( + + {alert.content} + + ))} + + + ) +} + +export function TerminationSummaryContextual() { + const { companyId, employeeId, payrollOption, onEvent } = + useFlow() + useI18n('Terminations.TerminationFlow') + const { t } = useTranslation('Terminations.TerminationFlow') + + const handleEvent = (event: EventType, data?: unknown) => { + if (event === componentEvents.EMPLOYEE_TERMINATION_CANCELLED) { + onEvent(event, { + ...(data as object), + alert: { + type: 'success', + title: t('cancelSuccess'), + }, + }) + return + } + onEvent(event, data) + } + + return ( + + ) +} diff --git a/src/components/Terminations/TerminationFlow/index.ts b/src/components/Terminations/TerminationFlow/index.ts new file mode 100644 index 00000000..a9d4fde4 --- /dev/null +++ b/src/components/Terminations/TerminationFlow/index.ts @@ -0,0 +1,2 @@ +export { TerminationFlow } from './TerminationFlow' +export type { TerminationFlowProps } from './TerminationFlowComponents' diff --git a/src/components/Terminations/TerminationFlow/terminationStateMachine.ts b/src/components/Terminations/TerminationFlow/terminationStateMachine.ts new file mode 100644 index 00000000..f8c9f61e --- /dev/null +++ b/src/components/Terminations/TerminationFlow/terminationStateMachine.ts @@ -0,0 +1,90 @@ +import { transition, reduce, state } from 'robot3' +import type { PayrollOption } from '../TerminateEmployee/TerminateEmployeePresentation' +import type { + TerminationFlowContextInterface, + TerminationFlowAlert, +} from './TerminationFlowComponents' +import { + TerminateEmployeeContextual, + TerminationSummaryContextual, +} from './TerminationFlowComponents' +import { componentEvents } from '@/shared/constants' +import type { MachineEventType, MachineTransition } from '@/types/Helpers' + +type EventPayloads = { + [componentEvents.EMPLOYEE_TERMINATION_DONE]: { + employeeId: string + effectiveDate: string + payrollOption: PayrollOption + } + [componentEvents.EMPLOYEE_TERMINATION_EDIT]: { + employeeId: string + } + [componentEvents.EMPLOYEE_TERMINATION_CANCELLED]: { + employeeId: string + alert?: TerminationFlowAlert + } + [componentEvents.EMPLOYEE_TERMINATION_RUN_PAYROLL]: { + employeeId: string + companyId: string + } + [componentEvents.EMPLOYEE_TERMINATION_RUN_OFF_CYCLE_PAYROLL]: { + employeeId: string + companyId: string + } +} + +export const terminationMachine = { + form: state( + transition( + componentEvents.EMPLOYEE_TERMINATION_DONE, + 'summary', + reduce( + ( + ctx: TerminationFlowContextInterface, + ev: MachineEventType, + ): TerminationFlowContextInterface => { + return { + ...ctx, + component: TerminationSummaryContextual, + payrollOption: ev.payload.payrollOption, + alerts: undefined, + } + }, + ), + ), + ), + summary: state( + transition( + componentEvents.EMPLOYEE_TERMINATION_EDIT, + 'form', + reduce((ctx: TerminationFlowContextInterface): TerminationFlowContextInterface => { + return { + ...ctx, + component: TerminateEmployeeContextual, + alerts: undefined, + } + }), + ), + transition( + componentEvents.EMPLOYEE_TERMINATION_CANCELLED, + 'form', + reduce( + ( + ctx: TerminationFlowContextInterface, + ev: MachineEventType< + EventPayloads, + typeof componentEvents.EMPLOYEE_TERMINATION_CANCELLED + >, + ): TerminationFlowContextInterface => { + return { + ...ctx, + component: TerminateEmployeeContextual, + alerts: ev.payload.alert ? [ev.payload.alert] : undefined, + payrollOption: undefined, + } + }, + ), + ), + ), +} diff --git a/src/components/Terminations/TerminationSummary/TerminationSummary.tsx b/src/components/Terminations/TerminationSummary/TerminationSummary.tsx index 140db2de..2dcd6ad4 100644 --- a/src/components/Terminations/TerminationSummary/TerminationSummary.tsx +++ b/src/components/Terminations/TerminationSummary/TerminationSummary.tsx @@ -1,3 +1,4 @@ +import { useState } from 'react' import { useQueryClient } from '@tanstack/react-query' import { useEmployeesGetSuspense } from '@gusto/embedded-api/react-query/employeesGet' import { @@ -37,6 +38,8 @@ const Root = ({ employeeId, companyId, payrollOption, dictionary }: TerminationS const queryClient = useQueryClient() const { onEvent, baseSubmitHandler } = useBase() + const [isCancelDialogOpen, setIsCancelDialogOpen] = useState(false) + const { data: { employee }, } = useEmployeesGetSuspense({ employeeId }) @@ -62,7 +65,15 @@ const Root = ({ employeeId, companyId, payrollOption, dictionary }: TerminationS !showRunOffCyclePayroll && (termination?.runTerminationPayroll === true || payrollOption === 'dismissalPayroll') - const handleCancelTermination = async () => { + const handleCancelClick = () => { + setIsCancelDialogOpen(true) + } + + const handleDialogClose = () => { + setIsCancelDialogOpen(false) + } + + const handleConfirmCancel = async () => { if (!termination) return await baseSubmitHandler({ terminationId: termination.uuid }, async () => { @@ -75,6 +86,8 @@ const Root = ({ employeeId, companyId, payrollOption, dictionary }: TerminationS await invalidateAllEmployeeEmploymentsGetTerminations(queryClient) await invalidateAllEmployeesList(queryClient) + setIsCancelDialogOpen(false) + onEvent(componentEvents.EMPLOYEE_TERMINATION_CANCELLED, { employeeId, termination, @@ -117,11 +130,15 @@ const Root = ({ employeeId, companyId, payrollOption, dictionary }: TerminationS canEdit={canEdit} showRunPayroll={showRunPayroll} showRunOffCyclePayroll={showRunOffCyclePayroll} - onCancelTermination={handleCancelTermination} + onCancelClick={handleCancelClick} onEditDismissal={handleEditDismissal} onRunDismissalPayroll={handleRunDismissalPayroll} onRunOffCyclePayroll={handleRunOffCyclePayroll} isLoading={isDeleting} + isCancelDialogOpen={isCancelDialogOpen} + onDialogClose={handleDialogClose} + onDialogConfirm={handleConfirmCancel} + isCancelling={isDeleting} /> ) } diff --git a/src/components/Terminations/TerminationSummary/TerminationSummaryPresentation.tsx b/src/components/Terminations/TerminationSummary/TerminationSummaryPresentation.tsx index ee85b40c..f3399f39 100644 --- a/src/components/Terminations/TerminationSummary/TerminationSummaryPresentation.tsx +++ b/src/components/Terminations/TerminationSummary/TerminationSummaryPresentation.tsx @@ -11,11 +11,15 @@ interface TerminationSummaryPresentationProps { canEdit: boolean showRunPayroll: boolean showRunOffCyclePayroll: boolean - onCancelTermination: () => void + onCancelClick: () => void onEditDismissal: () => void onRunDismissalPayroll: () => void onRunOffCyclePayroll: () => void isLoading: boolean + isCancelDialogOpen: boolean + onDialogClose: () => void + onDialogConfirm: () => void + isCancelling: boolean } export function TerminationSummaryPresentation({ @@ -25,13 +29,17 @@ export function TerminationSummaryPresentation({ canEdit, showRunPayroll, showRunOffCyclePayroll, - onCancelTermination, + onCancelClick, onEditDismissal, onRunDismissalPayroll, onRunOffCyclePayroll, isLoading, + isCancelDialogOpen, + onDialogClose, + onDialogConfirm, + isCancelling, }: TerminationSummaryPresentationProps) { - const { Alert, Heading, Text, Button, DescriptionList } = useComponentContext() + const { Alert, Heading, Text, Button, DescriptionList, Dialog } = useComponentContext() const { formatLongWithYear } = useDateFormatter() useI18n('Terminations.TerminationSummary') const { t } = useTranslation('Terminations.TerminationSummary') @@ -64,7 +72,7 @@ export function TerminationSummaryPresentation({ {hasActions && ( {canCancel && ( - )} @@ -85,6 +93,19 @@ export function TerminationSummaryPresentation({ )} )} + + + {t('cancelDialog.body')} + ) } diff --git a/src/components/Terminations/index.tsx b/src/components/Terminations/index.tsx index dd8ce7cc..f93544b6 100644 --- a/src/components/Terminations/index.tsx +++ b/src/components/Terminations/index.tsx @@ -1,3 +1,4 @@ export { TerminateEmployee } from './TerminateEmployee/TerminateEmployee' export { TerminationSummary } from './TerminationSummary/TerminationSummary' export { TerminationsData } from './TerminationsData' +export { TerminationFlow } from './TerminationFlow/TerminationFlow' diff --git a/src/i18n/en/Terminations.TerminationFlow.json b/src/i18n/en/Terminations.TerminationFlow.json new file mode 100644 index 00000000..7ed05d1c --- /dev/null +++ b/src/i18n/en/Terminations.TerminationFlow.json @@ -0,0 +1,3 @@ +{ + "cancelSuccess": "Termination has been cancelled successfully" +} diff --git a/src/i18n/en/Terminations.TerminationSummary.json b/src/i18n/en/Terminations.TerminationSummary.json index 1d0edeee..18626894 100644 --- a/src/i18n/en/Terminations.TerminationSummary.json +++ b/src/i18n/en/Terminations.TerminationSummary.json @@ -15,5 +15,11 @@ "editDismissal": "Edit termination", "runDismissalPayroll": "Run termination payroll", "runOffCyclePayroll": "Run off-cycle payroll" + }, + "cancelDialog": { + "title": "Cancel termination?", + "body": "Are you sure you want to cancel this termination? The employee will remain active.", + "confirm": "Yes, cancel termination", + "cancel": "No, go back" } } diff --git a/src/types/i18next.d.ts b/src/types/i18next.d.ts index 7d8d0826..bf2a3c55 100644 --- a/src/types/i18next.d.ts +++ b/src/types/i18next.d.ts @@ -1731,6 +1731,9 @@ export interface TerminationsTerminateEmployee{ "payrollOptionRequired":string; }; }; +export interface TerminationsTerminationFlow{ +"cancelSuccess":string; +}; export interface TerminationsTerminationSummary{ "title":string; "subtitle":string; @@ -1749,6 +1752,12 @@ export interface TerminationsTerminationSummary{ "runDismissalPayroll":string; "runOffCyclePayroll":string; }; +"cancelDialog":{ +"title":string; +"body":string; +"confirm":string; +"cancel":string; +}; }; export interface common{ "status":{ @@ -1924,6 +1933,6 @@ export interface common{ interface CustomTypeOptions { defaultNS: 'common'; - resources: { 'Company.Addresses': CompanyAddresses, 'Company.AssignSignatory': CompanyAssignSignatory, 'Company.BankAccount': CompanyBankAccount, 'Company.DocumentList': CompanyDocumentList, 'Company.FederalTaxes': CompanyFederalTaxes, 'Company.Industry': CompanyIndustry, 'Company.Locations': CompanyLocations, 'Company.OnboardingOverview': CompanyOnboardingOverview, 'Company.PaySchedule': CompanyPaySchedule, 'Company.SignatureForm': CompanySignatureForm, 'Company.StateTaxes': CompanyStateTaxes, 'Contractor.Address': ContractorAddress, 'Contractor.ContractorList': ContractorContractorList, 'Contractor.NewHireReport': ContractorNewHireReport, 'Contractor.PaymentMethod': ContractorPaymentMethod, 'Contractor.Payments.CreatePayment': ContractorPaymentsCreatePayment, 'Contractor.Payments.PaymentHistory': ContractorPaymentsPaymentHistory, 'Contractor.Payments.PaymentsList': ContractorPaymentsPaymentsList, 'Contractor.Profile': ContractorProfile, 'Contractor.Submit': ContractorSubmit, 'Employee.BankAccount': EmployeeBankAccount, 'Employee.Compensation': EmployeeCompensation, 'Employee.Deductions': EmployeeDeductions, 'Employee.DocumentSigner': EmployeeDocumentSigner, 'Employee.EmployeeList': EmployeeEmployeeList, 'Employee.FederalTaxes': EmployeeFederalTaxes, 'Employee.HomeAddress': EmployeeHomeAddress, 'Employee.Landing': EmployeeLanding, 'Employee.OnboardingSummary': EmployeeOnboardingSummary, 'Employee.PaySchedules': EmployeePaySchedules, 'Employee.PaymentMethod': EmployeePaymentMethod, 'Employee.Profile': EmployeeProfile, 'Employee.SplitPaycheck': EmployeeSplitPaycheck, 'Employee.StateTaxes': EmployeeStateTaxes, 'Employee.Taxes': EmployeeTaxes, 'Payroll.Common': PayrollCommon, 'Payroll.ConfirmWireDetailsBanner': PayrollConfirmWireDetailsBanner, 'Payroll.ConfirmWireDetailsForm': PayrollConfirmWireDetailsForm, 'Payroll.PayrollBlocker': PayrollPayrollBlocker, 'Payroll.PayrollConfiguration': PayrollPayrollConfiguration, 'Payroll.PayrollEditEmployee': PayrollPayrollEditEmployee, 'Payroll.PayrollFlow': PayrollPayrollFlow, 'Payroll.PayrollHistory': PayrollPayrollHistory, 'Payroll.PayrollLanding': PayrollPayrollLanding, 'Payroll.PayrollList': PayrollPayrollList, 'Payroll.PayrollOverview': PayrollPayrollOverview, 'Payroll.PayrollReceipts': PayrollPayrollReceipts, 'Payroll.WireInstructions': PayrollWireInstructions, 'Terminations.TerminateEmployee': TerminationsTerminateEmployee, 'Terminations.TerminationSummary': TerminationsTerminationSummary, 'common': common, } + resources: { 'Company.Addresses': CompanyAddresses, 'Company.AssignSignatory': CompanyAssignSignatory, 'Company.BankAccount': CompanyBankAccount, 'Company.DocumentList': CompanyDocumentList, 'Company.FederalTaxes': CompanyFederalTaxes, 'Company.Industry': CompanyIndustry, 'Company.Locations': CompanyLocations, 'Company.OnboardingOverview': CompanyOnboardingOverview, 'Company.PaySchedule': CompanyPaySchedule, 'Company.SignatureForm': CompanySignatureForm, 'Company.StateTaxes': CompanyStateTaxes, 'Contractor.Address': ContractorAddress, 'Contractor.ContractorList': ContractorContractorList, 'Contractor.NewHireReport': ContractorNewHireReport, 'Contractor.PaymentMethod': ContractorPaymentMethod, 'Contractor.Payments.CreatePayment': ContractorPaymentsCreatePayment, 'Contractor.Payments.PaymentHistory': ContractorPaymentsPaymentHistory, 'Contractor.Payments.PaymentsList': ContractorPaymentsPaymentsList, 'Contractor.Profile': ContractorProfile, 'Contractor.Submit': ContractorSubmit, 'Employee.BankAccount': EmployeeBankAccount, 'Employee.Compensation': EmployeeCompensation, 'Employee.Deductions': EmployeeDeductions, 'Employee.DocumentSigner': EmployeeDocumentSigner, 'Employee.EmployeeList': EmployeeEmployeeList, 'Employee.FederalTaxes': EmployeeFederalTaxes, 'Employee.HomeAddress': EmployeeHomeAddress, 'Employee.Landing': EmployeeLanding, 'Employee.OnboardingSummary': EmployeeOnboardingSummary, 'Employee.PaySchedules': EmployeePaySchedules, 'Employee.PaymentMethod': EmployeePaymentMethod, 'Employee.Profile': EmployeeProfile, 'Employee.SplitPaycheck': EmployeeSplitPaycheck, 'Employee.StateTaxes': EmployeeStateTaxes, 'Employee.Taxes': EmployeeTaxes, 'Payroll.Common': PayrollCommon, 'Payroll.ConfirmWireDetailsBanner': PayrollConfirmWireDetailsBanner, 'Payroll.ConfirmWireDetailsForm': PayrollConfirmWireDetailsForm, 'Payroll.PayrollBlocker': PayrollPayrollBlocker, 'Payroll.PayrollConfiguration': PayrollPayrollConfiguration, 'Payroll.PayrollEditEmployee': PayrollPayrollEditEmployee, 'Payroll.PayrollFlow': PayrollPayrollFlow, 'Payroll.PayrollHistory': PayrollPayrollHistory, 'Payroll.PayrollLanding': PayrollPayrollLanding, 'Payroll.PayrollList': PayrollPayrollList, 'Payroll.PayrollOverview': PayrollPayrollOverview, 'Payroll.PayrollReceipts': PayrollPayrollReceipts, 'Payroll.WireInstructions': PayrollWireInstructions, 'Terminations.TerminateEmployee': TerminationsTerminateEmployee, 'Terminations.TerminationFlow': TerminationsTerminationFlow, 'Terminations.TerminationSummary': TerminationsTerminationSummary, 'common': common, } }; } \ No newline at end of file From 7842802cd55510efc017c6c76797d6c5802d4675 Mon Sep 17 00:00:00 2001 From: Aaron Lee Date: Fri, 2 Jan 2026 15:01:43 -0500 Subject: [PATCH 11/12] fix: fix tests --- .../TerminationSummary.test.tsx | 22 ++++++++++++------- src/test/setup.ts | 8 +++++++ 2 files changed, 22 insertions(+), 8 deletions(-) diff --git a/src/components/Terminations/TerminationSummary/TerminationSummary.test.tsx b/src/components/Terminations/TerminationSummary/TerminationSummary.test.tsx index 56c85bca..a1a84414 100644 --- a/src/components/Terminations/TerminationSummary/TerminationSummary.test.tsx +++ b/src/components/Terminations/TerminationSummary/TerminationSummary.test.tsx @@ -135,7 +135,7 @@ describe('TerminationSummary', () => { renderWithProviders() await waitFor(() => { - expect(screen.getByRole('button', { name: 'Edit dismissal' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Edit termination' })).toBeInTheDocument() }) }) @@ -152,7 +152,7 @@ describe('TerminationSummary', () => { expect(screen.getByText('John Doe has been successfully terminated')).toBeInTheDocument() }) - expect(screen.queryByRole('button', { name: 'Edit dismissal' })).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Edit termination' })).not.toBeInTheDocument() }) it('shows run payroll button when dismissal payroll was selected', async () => { @@ -165,7 +165,7 @@ describe('TerminationSummary', () => { renderWithProviders() await waitFor(() => { - expect(screen.getByRole('button', { name: 'Run dismissal payroll' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Run termination payroll' })).toBeInTheDocument() }) }) @@ -183,7 +183,7 @@ describe('TerminationSummary', () => { }) expect( - screen.queryByRole('button', { name: 'Run dismissal payroll' }), + screen.queryByRole('button', { name: 'Run termination payroll' }), ).not.toBeInTheDocument() }) }) @@ -207,6 +207,12 @@ describe('TerminationSummary', () => { await user.click(screen.getByRole('button', { name: 'Cancel termination' })) + await waitFor(() => { + expect(screen.getByRole('button', { name: 'Yes, cancel termination' })).toBeInTheDocument() + }) + + await user.click(screen.getByRole('button', { name: 'Yes, cancel termination' })) + await waitFor(() => { expect(onEvent).toHaveBeenCalledWith( componentEvents.EMPLOYEE_TERMINATION_CANCELLED, @@ -227,10 +233,10 @@ describe('TerminationSummary', () => { renderWithProviders() await waitFor(() => { - expect(screen.getByRole('button', { name: 'Edit dismissal' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Edit termination' })).toBeInTheDocument() }) - await user.click(screen.getByRole('button', { name: 'Edit dismissal' })) + await user.click(screen.getByRole('button', { name: 'Edit termination' })) expect(onEvent).toHaveBeenCalledWith( componentEvents.EMPLOYEE_TERMINATION_EDIT, @@ -250,10 +256,10 @@ describe('TerminationSummary', () => { renderWithProviders() await waitFor(() => { - expect(screen.getByRole('button', { name: 'Run dismissal payroll' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Run termination payroll' })).toBeInTheDocument() }) - await user.click(screen.getByRole('button', { name: 'Run dismissal payroll' })) + await user.click(screen.getByRole('button', { name: 'Run termination payroll' })) expect(onEvent).toHaveBeenCalledWith( componentEvents.EMPLOYEE_TERMINATION_RUN_PAYROLL, diff --git a/src/test/setup.ts b/src/test/setup.ts index 96da1d4e..4f6b0de6 100644 --- a/src/test/setup.ts +++ b/src/test/setup.ts @@ -50,6 +50,14 @@ afterAll(() => { // Mock scrollIntoView Element.prototype.scrollIntoView = vi.fn() +// Mock HTMLDialogElement methods (jsdom doesn't support them) +HTMLDialogElement.prototype.showModal = vi.fn(function (this: HTMLDialogElement) { + this.open = true +}) +HTMLDialogElement.prototype.close = vi.fn(function (this: HTMLDialogElement) { + this.open = false +}) + expect.extend(toHaveNoViolations) // Make accessibility testing utilities globally available From f4b2ff25b72ecf3d81216b22cd6cea3e9c198936 Mon Sep 17 00:00:00 2001 From: Jeff Johnson Date: Fri, 2 Jan 2026 12:15:53 -0800 Subject: [PATCH 12/12] fix: regenerate i18n types to include DC state --- src/types/i18next.d.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/types/i18next.d.ts b/src/types/i18next.d.ts index bf2a3c55..9018ae5d 100644 --- a/src/types/i18next.d.ts +++ b/src/types/i18next.d.ts @@ -1851,6 +1851,7 @@ export interface common{ "CO":string; "CT":string; "DE":string; +"DC":string; "FL":string; "GA":string; "HI":string;