Skip to content

Commit bd0ecff

Browse files
feat(Dashboard): wire add-job and add-another-job compensation forms (#1898)
* feat(Dashboard): pending compensation change alerts on compensation card - Derive pending compensation changes across all jobs using a new getPendingCompensationChanges helper; stacked future comps are diffed chronologically so each bullet remains meaningful. - Single job with a pending change: inline warning alert with bullet details (pay, title, FLSA status, minimum-wage adjustments) and a Cancel change button that fires DELETE /v1/compensations/{uuid}. - Multiple jobs with one pending change: inline alert includes the job title ("Compensation for {title} will change on {date}") so the affected job is unambiguous. - Multiple jobs with multiple pending changes: summary alert with a Review CTA that opens a modal listing every pending change by job. - Alert uses disableScrollIntoView; change details render as an UnorderedList; cancellation fires directly without a confirmation dialog. - Extend Alert primitive with an optional action prop for inline CTAs. - createSdkQueryClient shared factory aligns QueryClient defaults; mutations invalidate via their own cache keys so no manual invalidateQueries calls are needed. - payRateFormats in common.json updated to long-form ("per hour", "per year") matching design copy; weekly/monthly remain annualised. - New EMPLOYEE_COMPENSATION_CHANGE_CANCELLED event emitted on success. - Full unit and integration test coverage for all alert variants, cancellation success/failure paths, stacked future comps, and the review modal flow. Co-authored-by: Cursor <cursoragent@cursor.com> * chore: remove temporary screenshots taken during development Co-authored-by: Cursor <cursoragent@cursor.com> * feat(Dashboard): add useFormatCompensationRate for non-annualized pay display Display weekly and monthly compensation rates as stored ($X per week, $X per month) rather than annualizing them. Introduces formatCompensationRate / useFormatCompensationRate alongside the existing formatPayRate so payroll contexts are unaffected. Also reverts payRateFormats i18n strings to original /hr /yr /paycheck compact format. Co-authored-by: Cursor <cursoragent@cursor.com> * feat(Dashboard): wire add-job and add-another-job compensation forms Replace the heading-only placeholder components in the employee dashboard compensation flow with real form components: - Add `AddAnotherJob` management component (withHireDateField: false, withEffectiveDateField: true) with tomorrow as minDate for effective date, matching gws-flows' future-date requirement - Extend `EditCompensation.startDate` to optional; derive `withHireDateField` internally so the add-job (empty state) flow renders a hire date field - Wire `AddJobContextual` and `AddAnotherJobContextual` into the dashboard state machine with `EMPLOYEE_COMPENSATION_UPDATED → index` success transitions and a `jobAdded` alert - Extract shared `AddCompensationFormBody` into `Compensation/shared/` to keep field rendering and translations in one place across both forms - Update copy: "Wage frequency" for payment unit label, "Start date" for hire date field, drop effective date description from the form body Co-authored-by: Cursor <cursoragent@cursor.com> * fix(Dashboard): guard JobFields.Title before using as JSX element Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent acb6194 commit bd0ecff

11 files changed

Lines changed: 430 additions & 196 deletions

File tree

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
.container {
2+
width: 100%;
3+
}
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
import { useState } from 'react'
2+
import classNames from 'classnames'
3+
import { useTranslation } from 'react-i18next'
4+
import { useJobForm } from '../../shared/useJobForm'
5+
import { useCompensationForm } from '../../shared/useCompensationForm'
6+
import { AddCompensationFormBody } from '../../shared/AddCompensationFormBody'
7+
import styles from './AddAnotherJob.module.scss'
8+
import { BaseBoundaries, BaseLayout, type CommonComponentInterface } from '@/components/Base'
9+
import type { OnEventType } from '@/components/Base/useBase'
10+
import { Form } from '@/components/Common/Form'
11+
import { useComponentDictionary, useI18n } from '@/i18n'
12+
import { composeErrorHandler } from '@/partner-hook-utils/composeErrorHandler'
13+
import { composeSubmitHandler } from '@/partner-hook-utils/form/composeSubmitHandler'
14+
import { componentEvents, type EventType } from '@/shared/constants'
15+
16+
export interface AddAnotherJobProps extends CommonComponentInterface<'Employee.Compensation'> {
17+
employeeId: string
18+
onCancel?: () => void
19+
onEvent: OnEventType<EventType, unknown>
20+
}
21+
22+
export function AddAnotherJob({ dictionary, ...props }: AddAnotherJobProps) {
23+
useComponentDictionary('Employee.Compensation', dictionary)
24+
return (
25+
<BaseBoundaries componentName="Employee.Compensation">
26+
<Root {...props} />
27+
</BaseBoundaries>
28+
)
29+
}
30+
31+
function Root({
32+
employeeId,
33+
onCancel,
34+
className,
35+
onEvent,
36+
}: Omit<AddAnotherJobProps, 'dictionary'>) {
37+
useI18n('Employee.Compensation')
38+
const { t } = useTranslation('Employee.Compensation')
39+
40+
// Track jobId locally so a partial-failure submit chain (job POST succeeds,
41+
// comp PUT fails) doesn't re-POST and create a duplicate job on retry.
42+
const [resolvedJobId, setResolvedJobId] = useState<string | undefined>(undefined)
43+
44+
const jobForm = useJobForm({
45+
employeeId,
46+
jobId: resolvedJobId,
47+
withHireDateField: false,
48+
optionalFieldsToRequire: { update: ['title'] },
49+
shouldFocusError: false,
50+
})
51+
52+
const resolvedCompensationId = jobForm.isLoading
53+
? undefined
54+
: (jobForm.data.currentJob?.currentCompensationUuid ?? undefined)
55+
56+
const compensationForm = useCompensationForm({
57+
employeeId,
58+
jobId: resolvedJobId,
59+
compensationId: resolvedCompensationId,
60+
withEffectiveDateField: true,
61+
optionalFieldsToRequire: { update: ['flsaStatus', 'rate', 'paymentUnit'] },
62+
shouldFocusError: false,
63+
})
64+
65+
if (jobForm.isLoading || compensationForm.isLoading) {
66+
const loadingErrorHandling = composeErrorHandler([jobForm, compensationForm])
67+
return <BaseLayout isLoading error={loadingErrorHandling.errors} />
68+
}
69+
70+
// The API defaults a secondary job's hire_date to the primary job's hire_date
71+
// when omitted. We pass it explicitly to satisfy the SDK hook's requirement
72+
// and mirror the API's own default behavior. React Query dedupes this query
73+
// since useJobForm has already loaded it.
74+
const primaryHireDate = jobForm.data.jobs?.find(j => j.primary)?.hireDate ?? undefined
75+
76+
const tomorrow = new Date()
77+
tomorrow.setDate(tomorrow.getDate() + 1)
78+
79+
const submitResult = composeSubmitHandler([jobForm, compensationForm], async () => {
80+
const jobResult = await jobForm.actions.onSubmit({ employeeId, hireDate: primaryHireDate })
81+
if (!jobResult) return
82+
83+
onEvent(componentEvents.EMPLOYEE_JOB_CREATED, jobResult.data)
84+
85+
const stubCompensation = jobResult.data.compensations?.find(
86+
c => c.uuid === jobResult.data.currentCompensationUuid,
87+
)
88+
89+
const compensationResult = await compensationForm.actions.onSubmit({
90+
jobId: jobResult.data.uuid,
91+
compensationId: jobResult.data.currentCompensationUuid ?? undefined,
92+
compensationVersion: stubCompensation?.version ?? undefined,
93+
})
94+
if (!compensationResult) {
95+
setResolvedJobId(jobResult.data.uuid)
96+
return
97+
}
98+
99+
onEvent(componentEvents.EMPLOYEE_COMPENSATION_UPDATED, compensationResult.data)
100+
})
101+
102+
const errorHandling = composeErrorHandler([submitResult])
103+
const isPending = jobForm.status.isPending || compensationForm.status.isPending
104+
105+
return (
106+
<section className={classNames(styles.container, className)}>
107+
<BaseLayout error={errorHandling.errors}>
108+
<Form onSubmit={submitResult.handleSubmit}>
109+
<AddCompensationFormBody
110+
jobForm={jobForm}
111+
compensationForm={compensationForm}
112+
title={t('addAnotherJobTitle')}
113+
submitCtaLabel={t('saveNewJobCta')}
114+
isPending={isPending}
115+
onCancel={onCancel}
116+
minEffectiveDate={tomorrow}
117+
/>
118+
</Form>
119+
</BaseLayout>
120+
</section>
121+
)
122+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
export { EditCompensation as ManagementEditCompensation } from './EditCompensation'
22
export type { EditCompensationProps as ManagementEditCompensationProps } from './EditCompensation'
3+
export * from './AddAnotherJob/AddAnotherJob'
Lines changed: 21 additions & 180 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,26 @@
11
import { useState } from 'react'
22
import classNames from 'classnames'
3-
import { Trans, useTranslation } from 'react-i18next'
4-
import type { PaymentUnit } from '@gusto/embedded-api/models/components/compensation'
5-
import type { FlsaStatusType } from '@gusto/embedded-api/models/components/flsastatustype'
6-
import type { MinimumWage } from '@gusto/embedded-api/models/components/minimumwage'
73
import type { CompensationDefaultValues } from '../Compensation'
8-
import { useJobForm, type UseJobFormReady } from '../../shared/useJobForm'
9-
import {
10-
useCompensationForm,
11-
type UseCompensationFormReady,
12-
} from '../../shared/useCompensationForm'
4+
import { useJobForm } from '../../shared/useJobForm'
5+
import { useCompensationForm } from '../../shared/useCompensationForm'
6+
import { AddCompensationFormBody } from '../../shared/AddCompensationFormBody'
137
import styles from './EditCompensation.module.scss'
148
import { BaseBoundaries, BaseLayout, type CommonComponentInterface } from '@/components/Base'
159
import type { OnEventType } from '@/components/Base/useBase'
16-
import { ActionsLayout, Flex } from '@/components/Common'
1710
import { Form } from '@/components/Common/Form'
18-
import { useComponentContext } from '@/contexts/ComponentAdapter/useComponentContext'
1911
import { useComponentDictionary, useI18n } from '@/i18n'
2012
import { composeErrorHandler } from '@/partner-hook-utils/composeErrorHandler'
2113
import { composeSubmitHandler } from '@/partner-hook-utils/form/composeSubmitHandler'
22-
import { componentEvents, FLSA_OVERTIME_SALARY_LIMIT, type EventType } from '@/shared/constants'
23-
import useNumberFormatter from '@/hooks/useNumberFormatter'
14+
import { componentEvents, type EventType } from '@/shared/constants'
2415

2516
export interface EditCompensationProps extends CommonComponentInterface<'Employee.Compensation'> {
2617
employeeId: string
27-
startDate: string
18+
/**
19+
* When provided, the hire date is pre-filled from this value and the hire
20+
* date field is hidden. When absent (add-job from empty state), the hire
21+
* date field is rendered so the user can set it explicitly.
22+
*/
23+
startDate?: string
2824
currentJobId?: string | null
2925
title: string
3026
submitCtaLabel: string
@@ -61,6 +57,11 @@ function Root({
6157
}: EditCompensationProps) {
6258
useI18n('Employee.Compensation')
6359

60+
// When startDate is provided (onboarding), hide the hire date field and derive
61+
// it from the prop at submit time. When absent (add-job from dashboard empty
62+
// state), render the field so the user can set it explicitly.
63+
const withHireDateField = !startDate
64+
6465
// Track jobId locally so a partial-failure submit chain (job POST succeeds,
6566
// comp PUT fails) doesn't re-POST and create a duplicate job on retry. We
6667
// initialize from the prop and only write back when the partner-supplied
@@ -70,11 +71,7 @@ function Root({
7071
const jobForm = useJobForm({
7172
employeeId,
7273
jobId: resolvedJobId,
73-
// The Compensation flow does not surface a hire-date field — the date is
74-
// derived from the employee's `startDate` (passed via submit options
75-
// below). Hiding via the hook flag also drops the field from the schema
76-
// so partner forms don't silently fail validation on create.
77-
withHireDateField: false,
74+
withHireDateField,
7875
defaultValues: {
7976
title: partnerDefaultValues?.title ?? '',
8077
},
@@ -129,7 +126,10 @@ function Root({
129126
}
130127

131128
const submitResult = composeSubmitHandler([jobForm, compensationForm], async () => {
132-
const jobResult = await jobForm.actions.onSubmit({ employeeId, hireDate: startDate })
129+
const jobResult = await jobForm.actions.onSubmit({
130+
employeeId,
131+
hireDate: startDate ?? undefined,
132+
})
133133
if (!jobResult) return
134134

135135
onEvent(
@@ -167,7 +167,7 @@ function Root({
167167
<section className={classNames(styles.container, className)}>
168168
<BaseLayout error={errorHandling.errors}>
169169
<Form onSubmit={submitResult.handleSubmit}>
170-
<FormBody
170+
<AddCompensationFormBody
171171
jobForm={jobForm}
172172
compensationForm={compensationForm}
173173
title={title}
@@ -180,162 +180,3 @@ function Root({
180180
</section>
181181
)
182182
}
183-
184-
interface FormBodyProps {
185-
jobForm: UseJobFormReady
186-
compensationForm: UseCompensationFormReady
187-
title: string
188-
submitCtaLabel: string
189-
isPending: boolean
190-
onCancel?: () => void
191-
}
192-
193-
function FormBody({
194-
jobForm,
195-
compensationForm,
196-
title,
197-
submitCtaLabel,
198-
isPending,
199-
onCancel,
200-
}: FormBodyProps) {
201-
const { t } = useTranslation('Employee.Compensation')
202-
const Components = useComponentContext()
203-
const format = useNumberFormatter('currency')
204-
205-
const JobFields = jobForm.form.Fields
206-
const CompFields = compensationForm.form.Fields
207-
208-
return (
209-
<Flex flexDirection="column" gap={32}>
210-
<Components.Heading as="h2">{title}</Components.Heading>
211-
212-
{compensationForm.status.willDeleteSecondaryJobs && (
213-
<Components.Alert
214-
label={t('validations.classificationChangeNotification')}
215-
status="warning"
216-
/>
217-
)}
218-
219-
{JobFields.Title && (
220-
<JobFields.Title
221-
label={t('jobTitle')}
222-
validationMessages={{ REQUIRED: t('validations.title') }}
223-
formHookResult={jobForm}
224-
/>
225-
)}
226-
227-
{CompFields.FlsaStatus && (
228-
<CompFields.FlsaStatus
229-
label={t('employeeClassification')}
230-
description={
231-
<Trans
232-
t={t}
233-
i18nKey="classificationLink"
234-
components={{ ClassificationLink: <Components.Link /> }}
235-
/>
236-
}
237-
validationMessages={{
238-
REQUIRED: t('validations.exemptThreshold', {
239-
limit: format(FLSA_OVERTIME_SALARY_LIMIT),
240-
}),
241-
}}
242-
getOptionLabel={(status: FlsaStatusType) => t(`flsaStatusLabels.${status}`)}
243-
formHookResult={compensationForm}
244-
/>
245-
)}
246-
247-
<CompFields.Rate
248-
label={t('amount')}
249-
validationMessages={{
250-
REQUIRED: t('validations.rate'),
251-
RATE_MINIMUM: t('validations.nonZeroRate'),
252-
RATE_EXEMPT_THRESHOLD: t('validations.rateExemptThreshold', {
253-
limit: format(FLSA_OVERTIME_SALARY_LIMIT),
254-
}),
255-
}}
256-
formHookResult={compensationForm}
257-
/>
258-
259-
<CompFields.PaymentUnit
260-
label={t('paymentUnitLabel')}
261-
description={t('paymentUnitDescription')}
262-
validationMessages={{ REQUIRED: t('validations.paymentUnit') }}
263-
getOptionLabel={(unit: PaymentUnit) => t(`paymentUnitOptions.${unit}`)}
264-
formHookResult={compensationForm}
265-
/>
266-
267-
{CompFields.AdjustForMinimumWage && (
268-
<CompFields.AdjustForMinimumWage
269-
label={t('adjustForMinimumWage')}
270-
description={t('adjustForMinimumWageDescription')}
271-
formHookResult={compensationForm}
272-
/>
273-
)}
274-
275-
{CompFields.MinimumWageId && (
276-
<CompFields.MinimumWageId
277-
label={t('minimumWageLabel')}
278-
description={t('minimumWageDescription')}
279-
validationMessages={{ REQUIRED: t('validations.minimumWage') }}
280-
getOptionLabel={(wage: MinimumWage) =>
281-
`${format(Number(wage.wage))} - ${wage.authority}: ${wage.notes ?? ''}`
282-
}
283-
formHookResult={compensationForm}
284-
/>
285-
)}
286-
287-
{JobFields.TwoPercentShareholder && (
288-
<JobFields.TwoPercentShareholder
289-
label={t('twoPercentStakeholderLabel')}
290-
formHookResult={jobForm}
291-
/>
292-
)}
293-
294-
{JobFields.StateWcCovered && (
295-
<JobFields.StateWcCovered
296-
label={t('stateWcCoveredLabel')}
297-
description={
298-
<Trans
299-
t={t}
300-
i18nKey="stateWcCoveredDescription"
301-
components={{
302-
wcLink: (
303-
<Components.Link
304-
href="https://www.lni.wa.gov/insurance/rates-risk-classes/risk-classes-for-workers-compensation/risk-class-lookup#/"
305-
target="_blank"
306-
rel="noopener noreferrer"
307-
/>
308-
),
309-
}}
310-
/>
311-
}
312-
getOptionLabel={(covered: boolean) =>
313-
covered ? t('stateWcCoveredOptions.yes') : t('stateWcCoveredOptions.no')
314-
}
315-
formHookResult={jobForm}
316-
/>
317-
)}
318-
319-
{JobFields.StateWcClassCode && (
320-
<JobFields.StateWcClassCode
321-
label={t('stateWcClassCodeLabel')}
322-
description={t('stateWcClassCodeDescription')}
323-
placeholder={t('stateWcClassCodeLabel')}
324-
validationMessages={{ REQUIRED: t('validations.stateWcClassCode') }}
325-
formHookResult={jobForm}
326-
/>
327-
)}
328-
329-
<ActionsLayout>
330-
{onCancel && (
331-
<Components.Button variant="secondary" onClick={onCancel} isDisabled={isPending}>
332-
{t('cancelNewJobCta')}
333-
</Components.Button>
334-
)}
335-
<Components.Button type="submit" isLoading={isPending}>
336-
{submitCtaLabel}
337-
</Components.Button>
338-
</ActionsLayout>
339-
</Flex>
340-
)
341-
}

0 commit comments

Comments
 (0)