From 14c5725cb8cf2b700b214aacdeec24b9890328a3 Mon Sep 17 00:00:00 2001 From: Aaron Lee Date: Mon, 1 Jun 2026 11:18:45 -0400 Subject: [PATCH 01/19] feat(dashboard): add CompensationHistory view from Job & pay tab Adds a read-only CompensationHistory component that lists every job with its full effective-dated compensation history (effective date, employee type, wage + frequency). Surfaced via a new tertiary "View history" button in the Job & pay compensation card, wired through the dashboard state machine alongside AddAnotherJob and EditCompensation. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../CompensationHistory.tsx | 115 ++++++++++++++++++ .../management/CompensationHistory/index.ts | 2 + .../Employee/Compensation/management/index.ts | 1 + .../Employee/Dashboard/Dashboard.tsx | 5 + .../Dashboard/DashboardComponents.tsx | 13 ++ .../Employee/Dashboard/JobAndPayView.tsx | 25 ++-- .../Dashboard/dashboardStateMachine.ts | 15 +++ src/i18n/en/Employee.Compensation.json | 6 + src/i18n/en/Employee.Dashboard.json | 1 + src/shared/constants.ts | 1 + src/types/i18next.d.ts | 7 ++ 11 files changed, 183 insertions(+), 8 deletions(-) create mode 100644 src/components/Employee/Compensation/management/CompensationHistory/CompensationHistory.tsx create mode 100644 src/components/Employee/Compensation/management/CompensationHistory/index.ts diff --git a/src/components/Employee/Compensation/management/CompensationHistory/CompensationHistory.tsx b/src/components/Employee/Compensation/management/CompensationHistory/CompensationHistory.tsx new file mode 100644 index 000000000..48d8a0842 --- /dev/null +++ b/src/components/Employee/Compensation/management/CompensationHistory/CompensationHistory.tsx @@ -0,0 +1,115 @@ +import { useTranslation } from 'react-i18next' +import { useJobsAndCompensationsGetJobs } from '@gusto/embedded-api/react-query/jobsAndCompensationsGetJobs' +import { GetV1EmployeesEmployeeIdJobsQueryParamInclude } from '@gusto/embedded-api/models/operations/getv1employeesemployeeidjobs' +import type { Job } from '@gusto/embedded-api/models/components/job' +import type { Compensation } from '@gusto/embedded-api/models/components/compensation' +import { BaseBoundaries, BaseLayout, type CommonComponentInterface } from '@/components/Base' +import { ActionsLayout, DataView, Flex, useDataView } from '@/components/Common' +import { useComponentContext } from '@/contexts/ComponentAdapter/useComponentContext' +import { formatDateLongWithYear } from '@/helpers/dateFormatting' +import { useFormatCompensationRate } from '@/helpers/formattedStrings' +import { useComponentDictionary, useI18n } from '@/i18n' +import { composeErrorHandler } from '@/partner-hook-utils/composeErrorHandler' + +export interface CompensationHistoryProps extends CommonComponentInterface<'Employee.Compensation'> { + employeeId: string + onBack?: () => void +} + +export function CompensationHistory({ dictionary, ...props }: CompensationHistoryProps) { + useComponentDictionary('Employee.Compensation', dictionary) + return ( + + + + ) +} + +function Root({ employeeId, onBack }: Omit) { + useI18n('Employee.Compensation') + const { t } = useTranslation('Employee.Compensation') + const Components = useComponentContext() + + const jobsQuery = useJobsAndCompensationsGetJobs( + { + employeeId, + include: GetV1EmployeesEmployeeIdJobsQueryParamInclude.AllCompensations, + }, + { enabled: !!employeeId }, + ) + + const errorHandling = composeErrorHandler([jobsQuery]) + + if (jobsQuery.isLoading || !jobsQuery.data) { + return + } + + const jobs = jobsQuery.data.jobs ?? [] + + return ( + + + {jobs.map(job => ( + + ))} + {onBack && ( + + + {t('backCta')} + + + )} + + + ) +} + +function JobCompensationHistory({ job }: { job: Job }) { + const { t } = useTranslation('Employee.Compensation') + const Components = useComponentContext() + const formatCompensationRate = useFormatCompensationRate() + + const currentComp = job.compensations?.find(c => c.uuid === job.currentCompensationUuid) + const jobTitle = currentComp?.title ?? job.title ?? '' + + const sortedCompensations = [...(job.compensations ?? [])].sort((a, b) => { + const aDate = a.effectiveDate ?? '' + const bDate = b.effectiveDate ?? '' + return bDate.localeCompare(aDate) + }) + + const dataViewProps = useDataView({ + data: sortedCompensations, + columns: [ + { + key: 'effectiveDate', + title: t('history.effectiveDateColumn'), + render: compensation => formatDateLongWithYear(compensation.effectiveDate), + }, + { + key: 'flsaStatus', + title: t('history.employeeTypeColumn'), + render: compensation => + compensation.flsaStatus !== undefined + ? t(`flsaStatusLabels.${compensation.flsaStatus}`) + : '', + }, + { + key: 'rate', + title: t('history.wageColumn'), + render: compensation => { + const rate = Number(compensation.rate) + if (!compensation.paymentUnit || Number.isNaN(rate)) return '' + return formatCompensationRate(rate, compensation.paymentUnit) + }, + }, + ], + }) + + return ( + + {jobTitle} + + + ) +} diff --git a/src/components/Employee/Compensation/management/CompensationHistory/index.ts b/src/components/Employee/Compensation/management/CompensationHistory/index.ts new file mode 100644 index 000000000..0163e0256 --- /dev/null +++ b/src/components/Employee/Compensation/management/CompensationHistory/index.ts @@ -0,0 +1,2 @@ +export { CompensationHistory } from './CompensationHistory' +export type { CompensationHistoryProps } from './CompensationHistory' diff --git a/src/components/Employee/Compensation/management/index.ts b/src/components/Employee/Compensation/management/index.ts index 480db1f57..f857add35 100644 --- a/src/components/Employee/Compensation/management/index.ts +++ b/src/components/Employee/Compensation/management/index.ts @@ -3,3 +3,4 @@ export type { EditCompensationProps as ManagementEditCompensationProps } from '. export { EditPendingCompensation as ManagementEditPendingCompensation } from './EditPendingCompensation/EditPendingCompensation' export type { EditPendingCompensationProps as ManagementEditPendingCompensationProps } from './EditPendingCompensation/EditPendingCompensation' export * from './AddAnotherJob/AddAnotherJob' +export * from './CompensationHistory' diff --git a/src/components/Employee/Dashboard/Dashboard.tsx b/src/components/Employee/Dashboard/Dashboard.tsx index 973aa0190..ea5daf78d 100644 --- a/src/components/Employee/Dashboard/Dashboard.tsx +++ b/src/components/Employee/Dashboard/Dashboard.tsx @@ -66,6 +66,10 @@ function DashboardRoot({ onEvent(componentEvents.EMPLOYEE_JOB_ADD_ANOTHER, { employeeId }) }, [onEvent, employeeId]) + const handleViewCompensationHistory = useCallback(() => { + onEvent(componentEvents.EMPLOYEE_COMPENSATION_HISTORY_VIEW, { employeeId }) + }, [onEvent, employeeId]) + const handleAddDeduction = useCallback(() => { onEvent(componentEvents.EMPLOYEE_DEDUCTION_ADD, { employeeId }) }, [onEvent, employeeId]) @@ -156,6 +160,7 @@ function DashboardRoot({ onEditCompensation={handleEditCompensation} onAddJob={handleAddJob} onAddAnotherJob={handleAddAnotherJob} + onViewHistory={handleViewCompensationHistory} onAddDeduction={handleAddDeduction} onEditDeduction={handleEditDeduction} /> diff --git a/src/components/Employee/Dashboard/DashboardComponents.tsx b/src/components/Employee/Dashboard/DashboardComponents.tsx index 7f1765b80..fca7b2f1a 100644 --- a/src/components/Employee/Dashboard/DashboardComponents.tsx +++ b/src/components/Employee/Dashboard/DashboardComponents.tsx @@ -17,6 +17,7 @@ import { } from '@/components/Employee/Compensation/management' import { useDeductionsList } from '@/components/Employee/Deductions/shared' import { AddAnotherJob } from '@/components/Employee/Compensation/management/AddAnotherJob/AddAnotherJob' +import { CompensationHistory } from '@/components/Employee/Compensation/management/CompensationHistory' import { EditCompensation } from '@/components/Employee/Compensation/onboarding/EditCompensation/EditCompensation' import { useFlow, type FlowContextInterface } from '@/components/Flow/useFlow' import { useComponentContext } from '@/contexts/ComponentAdapter/useComponentContext' @@ -239,3 +240,15 @@ export function AddAnotherJobContextual() { /> ) } + +export function CompensationHistoryContextual() { + const { employeeId, onEvent } = useFlow() + return ( + { + onEvent(componentEvents.CANCEL) + }} + /> + ) +} diff --git a/src/components/Employee/Dashboard/JobAndPayView.tsx b/src/components/Employee/Dashboard/JobAndPayView.tsx index ab88d3797..982036d8b 100644 --- a/src/components/Employee/Dashboard/JobAndPayView.tsx +++ b/src/components/Employee/Dashboard/JobAndPayView.tsx @@ -59,6 +59,7 @@ export interface JobAndPayViewProps { onEditCompensation?: (job: Job) => void onAddJob?: () => void onAddAnotherJob?: () => void + onViewHistory?: () => void onAddDeduction?: () => void onEditDeduction?: (deduction: Garnishment) => void } @@ -69,6 +70,7 @@ export function JobAndPayView({ onEditCompensation, onAddJob, onAddAnotherJob, + onViewHistory, onAddDeduction, onEditDeduction, }: JobAndPayViewProps) { @@ -617,14 +619,21 @@ export function JobAndPayView({ /> } footer={ - !isCompensationCardLoading && canAddAnotherJob ? ( - } - > - {t('jobAndPay.compensation.addAnotherJobCta')} - + !isCompensationCardLoading && jobs.length > 0 ? ( + + {canAddAnotherJob && ( + } + > + {t('jobAndPay.compensation.addAnotherJobCta')} + + )} + + {t('jobAndPay.compensation.viewHistoryCta')} + + ) : undefined } > diff --git a/src/components/Employee/Dashboard/dashboardStateMachine.ts b/src/components/Employee/Dashboard/dashboardStateMachine.ts index 519aa5b47..09827a273 100644 --- a/src/components/Employee/Dashboard/dashboardStateMachine.ts +++ b/src/components/Employee/Dashboard/dashboardStateMachine.ts @@ -16,6 +16,7 @@ import { AddJobContextual, EditCompensationContextual, AddAnotherJobContextual, + CompensationHistoryContextual, type DashboardContextInterface, } from './DashboardComponents' import { componentEvents } from '@/shared/constants' @@ -182,6 +183,17 @@ export const dashboardStateMachine = { }), ), ), + transition( + componentEvents.EMPLOYEE_COMPENSATION_HISTORY_VIEW, + 'compensationHistory', + reduce( + (ctx: DashboardContextInterface): DashboardContextInterface => ({ + ...ctx, + component: CompensationHistoryContextual, + successAlert: null, + }), + ), + ), transition( componentEvents.EMPLOYEE_BANK_ACCOUNT_DELETED, 'index', @@ -314,4 +326,7 @@ export const dashboardStateMachine = { ), transition(componentEvents.CANCEL, 'index', returnToIndex), ), + compensationHistory: state( + transition(componentEvents.CANCEL, 'index', returnToIndex), + ), } diff --git a/src/i18n/en/Employee.Compensation.json b/src/i18n/en/Employee.Compensation.json index 1b02aac78..24bab3ff4 100644 --- a/src/i18n/en/Employee.Compensation.json +++ b/src/i18n/en/Employee.Compensation.json @@ -29,6 +29,12 @@ "Salaried Nonexempt": "Salary/Eligible for overtime" }, "hamburgerTitle": "Job actions", + "history": { + "effectiveDateColumn": "Effective date", + "employeeTypeColumn": "Employee type", + "wageColumn": "Wage", + "tableLabel": "Compensation history for {{jobTitle}}" + }, "jobTitle": "Job Title", "paymentUnitDescription": "The period over which the compensation amount is tracked (e.g., hourly, daily, weekly, monthly, annually).", "paymentUnitLabel": "Wage frequency", diff --git a/src/i18n/en/Employee.Dashboard.json b/src/i18n/en/Employee.Dashboard.json index 9652725dc..79e264712 100644 --- a/src/i18n/en/Employee.Dashboard.json +++ b/src/i18n/en/Employee.Dashboard.json @@ -44,6 +44,7 @@ "effectiveDate": "Effective date", "addJobCta": "Add job", "addAnotherJobCta": "Add another job", + "viewHistoryCta": "View history", "tableLabel": "List of jobs", "hamburgerTitle": "Job actions", "editJobCta": "Edit", diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 4541dcf64..20938c404 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -38,6 +38,7 @@ export const employeeEvents = { EMPLOYEE_COMPENSATION_CANCEL: 'employee/compensations/cancel', EMPLOYEE_COMPENSATION_CHANGE_CANCELLED: 'employee/compensations/changeCancelled', EMPLOYEE_COMPENSATION_RETURN_TO_LIST: 'employee/compensations/returnToList', + EMPLOYEE_COMPENSATION_HISTORY_VIEW: 'employee/compensations/historyView', EMPLOYEE_JOB_ADD: 'employee/job/add', EMPLOYEE_JOB_ADD_ANOTHER: 'employee/job/addAnother', EMPLOYEE_JOB_EDIT: 'employee/job/edit', diff --git a/src/types/i18next.d.ts b/src/types/i18next.d.ts index 1efaa74e4..3f345ae5c 100644 --- a/src/types/i18next.d.ts +++ b/src/types/i18next.d.ts @@ -1361,6 +1361,12 @@ export interface EmployeeCompensation{ "Salaried Nonexempt":string; }; "hamburgerTitle":string; +"history":{ +"effectiveDateColumn":string; +"employeeTypeColumn":string; +"wageColumn":string; +"tableLabel":string; +}; "jobTitle":string; "paymentUnitDescription":string; "paymentUnitLabel":string; @@ -1461,6 +1467,7 @@ export interface EmployeeDashboard{ "effectiveDate":string; "addJobCta":string; "addAnotherJobCta":string; +"viewHistoryCta":string; "tableLabel":string; "hamburgerTitle":string; "editJobCta":string; From e4355d3aad810bf2826e9461cb1f8c3b6850a1ec Mon Sep 17 00:00:00 2001 From: Aaron Lee Date: Mon, 1 Jun 2026 17:01:10 -0400 Subject: [PATCH 02/19] feat(dashboard): combine multi-job compensation history into a filterable single view - Render a single combined DataView (with Job title column) when the employee has 2+ jobs, sorted by effective date descending across jobs. - Add a Job filter Select beside the heading that defaults to "All jobs" and narrows rows to the chosen job. - Keep the per-job heading + 3-column DataView when there's only one job. - Show an empty state when the employee has no jobs. - Use useContainerBreakpoints + FlexItem to stack the heading and Select in narrow containers (matches the PayrollConfiguration header pattern). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../CompensationHistory.module.scss | 12 ++ .../CompensationHistory.test.tsx | 82 ++++++++++++ .../CompensationHistory.tsx | 124 ++++++++++++++++-- src/i18n/en/Employee.Compensation.json | 8 +- src/types/i18next.d.ts | 6 + 5 files changed, 220 insertions(+), 12 deletions(-) create mode 100644 src/components/Employee/Compensation/management/CompensationHistory/CompensationHistory.module.scss create mode 100644 src/components/Employee/Compensation/management/CompensationHistory/CompensationHistory.test.tsx diff --git a/src/components/Employee/Compensation/management/CompensationHistory/CompensationHistory.module.scss b/src/components/Employee/Compensation/management/CompensationHistory/CompensationHistory.module.scss new file mode 100644 index 000000000..9fa2e10cc --- /dev/null +++ b/src/components/Employee/Compensation/management/CompensationHistory/CompensationHistory.module.scss @@ -0,0 +1,12 @@ +.container { + container-type: inline-size; + width: 100%; +} + +.jobFilter { + width: 100%; + + @container (min-width: 40rem) { + width: toRem(280); + } +} diff --git a/src/components/Employee/Compensation/management/CompensationHistory/CompensationHistory.test.tsx b/src/components/Employee/Compensation/management/CompensationHistory/CompensationHistory.test.tsx new file mode 100644 index 000000000..e0b8d4689 --- /dev/null +++ b/src/components/Employee/Compensation/management/CompensationHistory/CompensationHistory.test.tsx @@ -0,0 +1,82 @@ +import { beforeEach, describe, expect, it } from 'vitest' +import { screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { HttpResponse } from 'msw' +import { CompensationHistory } from './CompensationHistory' +import { server } from '@/test/mocks/server' +import { handleGetEmployeeJobs } from '@/test/mocks/apis/employees' +import { setupApiTestMocks } from '@/test/mocks/apiServer' +import { buildEmployeeWithJobs } from '@/test/factories/jobsAndCompensations' +import { renderWithProviders } from '@/test-utils/renderWithProviders' +import { mockUseContainerBreakpoints } from '@/test/setup' + +describe('Employee.Compensation.CompensationHistory', () => { + beforeEach(() => { + setupApiTestMocks() + // Force the DataView's container-breakpoints hook to report a desktop + // width so DataTable (not DataCards) renders — lets the tests use + // table-role queries. + mockUseContainerBreakpoints.mockReturnValue(['base', 'small', 'medium', 'large']) + }) + + it('renders the job title as heading and omits the job filter for a single job', async () => { + server.use( + handleGetEmployeeJobs(() => + HttpResponse.json(buildEmployeeWithJobs({ scenario: 'singleNonexempt' })), + ), + ) + + renderWithProviders() + + expect(await screen.findByRole('heading', { name: 'My Job' })).toBeInTheDocument() + expect(screen.queryByRole('button', { name: /All jobs/i })).not.toBeInTheDocument() + expect(screen.queryByRole('columnheader', { name: 'Job title' })).not.toBeInTheDocument() + }) + + it('renders the combined view with both jobs by default when there are multiple jobs', async () => { + server.use( + handleGetEmployeeJobs(() => + HttpResponse.json(buildEmployeeWithJobs({ scenario: 'multiJob' })), + ), + ) + + renderWithProviders() + + expect(await screen.findByRole('heading', { name: 'Compensation history' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: /All jobs/i })).toBeInTheDocument() + expect(screen.getByRole('columnheader', { name: 'Job title' })).toBeInTheDocument() + expect(screen.getByRole('gridcell', { name: 'My Job' })).toBeInTheDocument() + expect(screen.getByRole('gridcell', { name: 'An additional job' })).toBeInTheDocument() + }) + + it('filters rows to the selected job when a job is chosen from the filter', async () => { + server.use( + handleGetEmployeeJobs(() => + HttpResponse.json(buildEmployeeWithJobs({ scenario: 'multiJob' })), + ), + ) + + const user = userEvent.setup() + renderWithProviders() + + await screen.findByRole('heading', { name: 'Compensation history' }) + + await user.click(screen.getByRole('button', { name: /All jobs/i })) + await user.click(screen.getByRole('option', { name: 'An additional job' })) + + expect(screen.getByRole('gridcell', { name: 'An additional job' })).toBeInTheDocument() + expect(screen.queryByRole('gridcell', { name: 'My Job' })).not.toBeInTheDocument() + }) + + it('renders an empty state when the employee has no jobs', async () => { + server.use( + handleGetEmployeeJobs(() => HttpResponse.json(buildEmployeeWithJobs({ scenario: 'noJobs' }))), + ) + + renderWithProviders() + + expect(await screen.findByText('No compensation history yet.')).toBeInTheDocument() + expect(screen.queryByRole('heading', { name: 'Compensation history' })).not.toBeInTheDocument() + expect(screen.queryByRole('grid')).not.toBeInTheDocument() + }) +}) diff --git a/src/components/Employee/Compensation/management/CompensationHistory/CompensationHistory.tsx b/src/components/Employee/Compensation/management/CompensationHistory/CompensationHistory.tsx index 48d8a0842..60d80ffdc 100644 --- a/src/components/Employee/Compensation/management/CompensationHistory/CompensationHistory.tsx +++ b/src/components/Employee/Compensation/management/CompensationHistory/CompensationHistory.tsx @@ -1,10 +1,13 @@ +import { useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { useJobsAndCompensationsGetJobs } from '@gusto/embedded-api/react-query/jobsAndCompensationsGetJobs' import { GetV1EmployeesEmployeeIdJobsQueryParamInclude } from '@gusto/embedded-api/models/operations/getv1employeesemployeeidjobs' import type { Job } from '@gusto/embedded-api/models/components/job' import type { Compensation } from '@gusto/embedded-api/models/components/compensation' +import style from './CompensationHistory.module.scss' import { BaseBoundaries, BaseLayout, type CommonComponentInterface } from '@/components/Base' -import { ActionsLayout, DataView, Flex, useDataView } from '@/components/Common' +import { ActionsLayout, DataView, Flex, FlexItem, useDataView } from '@/components/Common' +import useContainerBreakpoints from '@/hooks/useContainerBreakpoints/useContainerBreakpoints' import { useComponentContext } from '@/contexts/ComponentAdapter/useComponentContext' import { formatDateLongWithYear } from '@/helpers/dateFormatting' import { useFormatCompensationRate } from '@/helpers/formattedStrings' @@ -49,9 +52,13 @@ function Root({ employeeId, onBack }: Omit - {jobs.map(job => ( - - ))} + {jobs.length === 0 ? ( + {t('history.emptyState')} + ) : jobs.length === 1 && jobs[0] ? ( + + ) : ( + + )} {onBack && ( @@ -64,19 +71,21 @@ function Root({ employeeId, onBack }: Omit c.uuid === job.currentCompensationUuid) + return currentComp?.title ?? job.title ?? '' +} + function JobCompensationHistory({ job }: { job: Job }) { const { t } = useTranslation('Employee.Compensation') const Components = useComponentContext() const formatCompensationRate = useFormatCompensationRate() - const currentComp = job.compensations?.find(c => c.uuid === job.currentCompensationUuid) - const jobTitle = currentComp?.title ?? job.title ?? '' + const jobTitle = getJobTitle(job) - const sortedCompensations = [...(job.compensations ?? [])].sort((a, b) => { - const aDate = a.effectiveDate ?? '' - const bDate = b.effectiveDate ?? '' - return bDate.localeCompare(aDate) - }) + const sortedCompensations = [...(job.compensations ?? [])].sort((a, b) => + (b.effectiveDate ?? '').localeCompare(a.effectiveDate ?? ''), + ) const dataViewProps = useDataView({ data: sortedCompensations, @@ -113,3 +122,96 @@ function JobCompensationHistory({ job }: { job: Job }) { ) } + +type CombinedRow = { + compensation: Compensation + job: Job +} + +function CombinedCompensationHistory({ jobs }: { jobs: Job[] }) { + const { t } = useTranslation('Employee.Compensation') + const Components = useComponentContext() + const formatCompensationRate = useFormatCompensationRate() + const [selectedJobUuid, setSelectedJobUuid] = useState('all') + const containerRef = useRef(null) + const breakpoints = useContainerBreakpoints({ ref: containerRef }) + const isDesktop = breakpoints.includes('small') + + const visibleJobs = + selectedJobUuid === 'all' ? jobs : jobs.filter(job => job.uuid === selectedJobUuid) + + const rows: CombinedRow[] = visibleJobs + .flatMap(job => (job.compensations ?? []).map(compensation => ({ compensation, job }))) + .sort((a, b) => + (b.compensation.effectiveDate ?? '').localeCompare(a.compensation.effectiveDate ?? ''), + ) + + const options = [ + { value: 'all', label: t('history.allJobsOption') }, + ...jobs.map(job => ({ value: job.uuid, label: getJobTitle(job) })), + ] + + const dataViewProps = useDataView({ + data: rows, + columns: [ + { + key: 'effectiveDate', + title: t('history.effectiveDateColumn'), + render: row => formatDateLongWithYear(row.compensation.effectiveDate), + }, + { + key: 'title', + title: t('history.jobTitleColumn'), + render: row => row.compensation.title ?? row.job.title ?? '', + }, + { + key: 'flsaStatus', + title: t('history.employeeTypeColumn'), + render: row => + row.compensation.flsaStatus !== undefined + ? t(`flsaStatusLabels.${row.compensation.flsaStatus}`) + : '', + }, + { + key: 'rate', + title: t('history.wageColumn'), + render: row => { + const rate = Number(row.compensation.rate) + if (!row.compensation.paymentUnit || Number.isNaN(rate)) return '' + return formatCompensationRate(rate, row.compensation.paymentUnit) + }, + }, + ], + }) + + return ( +
+ + + + {t('history.heading')} + + +
+ { + setSelectedJobUuid(value) + }} + options={options} + /> +
+
+
+ +
+
+ ) +} diff --git a/src/i18n/en/Employee.Compensation.json b/src/i18n/en/Employee.Compensation.json index 24bab3ff4..23d589d73 100644 --- a/src/i18n/en/Employee.Compensation.json +++ b/src/i18n/en/Employee.Compensation.json @@ -33,7 +33,13 @@ "effectiveDateColumn": "Effective date", "employeeTypeColumn": "Employee type", "wageColumn": "Wage", - "tableLabel": "Compensation history for {{jobTitle}}" + "tableLabel": "Compensation history for {{jobTitle}}", + "jobTitleColumn": "Job title", + "combinedTableLabel": "Compensation history across all jobs", + "heading": "Compensation history", + "jobFilterLabel": "Filter by job", + "allJobsOption": "All jobs", + "emptyState": "No compensation history yet." }, "jobTitle": "Job Title", "paymentUnitDescription": "The period over which the compensation amount is tracked (e.g., hourly, daily, weekly, monthly, annually).", diff --git a/src/types/i18next.d.ts b/src/types/i18next.d.ts index 3f345ae5c..5d75e27c8 100644 --- a/src/types/i18next.d.ts +++ b/src/types/i18next.d.ts @@ -1366,6 +1366,12 @@ export interface EmployeeCompensation{ "employeeTypeColumn":string; "wageColumn":string; "tableLabel":string; +"jobTitleColumn":string; +"combinedTableLabel":string; +"heading":string; +"jobFilterLabel":string; +"allJobsOption":string; +"emptyState":string; }; "jobTitle":string; "paymentUnitDescription":string; From f1d9f556d31fbeaf2685af405102fc7c1ef3251f Mon Sep 17 00:00:00 2001 From: Aaron Lee Date: Mon, 1 Jun 2026 17:04:53 -0400 Subject: [PATCH 03/19] refactor: drop FlexItem wrappers from CompensationHistory header MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FlexItem with no props is a div with flex-grow: initial, which is the default for any flex child — the wrappers were not load-bearing. Heading and the .jobFilter div behave identically as direct Flex children. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../CompensationHistory.tsx | 30 ++++++++----------- 1 file changed, 13 insertions(+), 17 deletions(-) diff --git a/src/components/Employee/Compensation/management/CompensationHistory/CompensationHistory.tsx b/src/components/Employee/Compensation/management/CompensationHistory/CompensationHistory.tsx index 60d80ffdc..ba8968f3f 100644 --- a/src/components/Employee/Compensation/management/CompensationHistory/CompensationHistory.tsx +++ b/src/components/Employee/Compensation/management/CompensationHistory/CompensationHistory.tsx @@ -6,7 +6,7 @@ import type { Job } from '@gusto/embedded-api/models/components/job' import type { Compensation } from '@gusto/embedded-api/models/components/compensation' import style from './CompensationHistory.module.scss' import { BaseBoundaries, BaseLayout, type CommonComponentInterface } from '@/components/Base' -import { ActionsLayout, DataView, Flex, FlexItem, useDataView } from '@/components/Common' +import { ActionsLayout, DataView, Flex, useDataView } from '@/components/Common' import useContainerBreakpoints from '@/hooks/useContainerBreakpoints/useContainerBreakpoints' import { useComponentContext } from '@/contexts/ComponentAdapter/useComponentContext' import { formatDateLongWithYear } from '@/helpers/dateFormatting' @@ -193,22 +193,18 @@ function CombinedCompensationHistory({ jobs }: { jobs: Job[] }) { alignItems={isDesktop ? 'center' : 'stretch'} gap={isDesktop ? 0 : 16} > - - {t('history.heading')} - - -
- { - setSelectedJobUuid(value) - }} - options={options} - /> -
-
+ {t('history.heading')} +
+ { + setSelectedJobUuid(value) + }} + options={options} + /> +
From 551ba05d4f1a5ab85bb6aaf7d1b00c0316317fef Mon Sep 17 00:00:00 2001 From: Aaron Lee Date: Mon, 1 Jun 2026 17:36:17 -0400 Subject: [PATCH 04/19] chore: relocate CompensationHistory to sdk-app design prototypes Move the read-only CompensationHistory component out of the SDK surface and into sdk-app/src/design/prototypes/employee-management/CompensationHistory. Strings are hardcoded (no i18n dependency) since prototypes don't use the SDK's translation system. Revert all SDK wiring back to main: - Remove CompensationHistoryContextual from DashboardComponents - Remove compensationHistory state + EMPLOYEE_COMPENSATION_HISTORY_VIEW transition from dashboardStateMachine - Drop the "View history" tertiary button from JobAndPayView - Remove handleViewCompensationHistory wiring from Dashboard - Remove the export from Compensation/management/index.ts - Remove the EMPLOYEE_COMPENSATION_HISTORY_VIEW event constant - Remove the history.* translation keys and viewHistoryCta Wire the prototype into the design app, matching the existing contractor-management folder/index.tsx convention: - index.tsx reads employeeId from outlet context (optional prop kept for testability) - New route /design/employee-compensation-history in sdk-app main router - Register the prototype under the Employees category in design/registry Co-Authored-By: Claude Opus 4.7 (1M context) --- .../CompensationHistory.module.scss | 0 .../CompensationHistory.test.tsx | 4 +- .../CompensationHistory/index.tsx | 94 ++++++++++++------- sdk-app/src/design/registry.ts | 9 +- sdk-app/src/main.tsx | 2 + .../management/CompensationHistory/index.ts | 2 - .../Employee/Compensation/management/index.ts | 1 - .../Employee/Dashboard/Dashboard.tsx | 5 - .../Dashboard/DashboardComponents.tsx | 13 --- .../Employee/Dashboard/JobAndPayView.tsx | 25 ++--- .../Dashboard/dashboardStateMachine.ts | 15 --- src/i18n/en/Employee.Compensation.json | 12 --- src/i18n/en/Employee.Dashboard.json | 1 - src/shared/constants.ts | 1 - src/types/i18next.d.ts | 13 --- 15 files changed, 78 insertions(+), 119 deletions(-) rename {src/components/Employee/Compensation/management => sdk-app/src/design/prototypes/employee-management}/CompensationHistory/CompensationHistory.module.scss (100%) rename {src/components/Employee/Compensation/management => sdk-app/src/design/prototypes/employee-management}/CompensationHistory/CompensationHistory.test.tsx (96%) rename src/components/Employee/Compensation/management/CompensationHistory/CompensationHistory.tsx => sdk-app/src/design/prototypes/employee-management/CompensationHistory/index.tsx (70%) delete mode 100644 src/components/Employee/Compensation/management/CompensationHistory/index.ts diff --git a/src/components/Employee/Compensation/management/CompensationHistory/CompensationHistory.module.scss b/sdk-app/src/design/prototypes/employee-management/CompensationHistory/CompensationHistory.module.scss similarity index 100% rename from src/components/Employee/Compensation/management/CompensationHistory/CompensationHistory.module.scss rename to sdk-app/src/design/prototypes/employee-management/CompensationHistory/CompensationHistory.module.scss diff --git a/src/components/Employee/Compensation/management/CompensationHistory/CompensationHistory.test.tsx b/sdk-app/src/design/prototypes/employee-management/CompensationHistory/CompensationHistory.test.tsx similarity index 96% rename from src/components/Employee/Compensation/management/CompensationHistory/CompensationHistory.test.tsx rename to sdk-app/src/design/prototypes/employee-management/CompensationHistory/CompensationHistory.test.tsx index e0b8d4689..e8e46f822 100644 --- a/src/components/Employee/Compensation/management/CompensationHistory/CompensationHistory.test.tsx +++ b/sdk-app/src/design/prototypes/employee-management/CompensationHistory/CompensationHistory.test.tsx @@ -2,7 +2,7 @@ import { beforeEach, describe, expect, it } from 'vitest' import { screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { HttpResponse } from 'msw' -import { CompensationHistory } from './CompensationHistory' +import { CompensationHistory } from './' import { server } from '@/test/mocks/server' import { handleGetEmployeeJobs } from '@/test/mocks/apis/employees' import { setupApiTestMocks } from '@/test/mocks/apiServer' @@ -10,7 +10,7 @@ import { buildEmployeeWithJobs } from '@/test/factories/jobsAndCompensations' import { renderWithProviders } from '@/test-utils/renderWithProviders' import { mockUseContainerBreakpoints } from '@/test/setup' -describe('Employee.Compensation.CompensationHistory', () => { +describe('prototypes/employee-management/CompensationHistory', () => { beforeEach(() => { setupApiTestMocks() // Force the DataView's container-breakpoints hook to report a desktop diff --git a/src/components/Employee/Compensation/management/CompensationHistory/CompensationHistory.tsx b/sdk-app/src/design/prototypes/employee-management/CompensationHistory/index.tsx similarity index 70% rename from src/components/Employee/Compensation/management/CompensationHistory/CompensationHistory.tsx rename to sdk-app/src/design/prototypes/employee-management/CompensationHistory/index.tsx index ba8968f3f..8b0b95787 100644 --- a/src/components/Employee/Compensation/management/CompensationHistory/CompensationHistory.tsx +++ b/sdk-app/src/design/prototypes/employee-management/CompensationHistory/index.tsx @@ -1,36 +1,61 @@ import { useRef, useState } from 'react' -import { useTranslation } from 'react-i18next' +import { useOutletContext } from 'react-router-dom' import { useJobsAndCompensationsGetJobs } from '@gusto/embedded-api/react-query/jobsAndCompensationsGetJobs' import { GetV1EmployeesEmployeeIdJobsQueryParamInclude } from '@gusto/embedded-api/models/operations/getv1employeesemployeeidjobs' import type { Job } from '@gusto/embedded-api/models/components/job' import type { Compensation } from '@gusto/embedded-api/models/components/compensation' +import type { EntityIds } from '../../../../useEntities' import style from './CompensationHistory.module.scss' -import { BaseBoundaries, BaseLayout, type CommonComponentInterface } from '@/components/Base' +import { BaseBoundaries, BaseLayout } from '@/components/Base' import { ActionsLayout, DataView, Flex, useDataView } from '@/components/Common' import useContainerBreakpoints from '@/hooks/useContainerBreakpoints/useContainerBreakpoints' import { useComponentContext } from '@/contexts/ComponentAdapter/useComponentContext' import { formatDateLongWithYear } from '@/helpers/dateFormatting' import { useFormatCompensationRate } from '@/helpers/formattedStrings' -import { useComponentDictionary, useI18n } from '@/i18n' import { composeErrorHandler } from '@/partner-hook-utils/composeErrorHandler' -export interface CompensationHistoryProps extends CommonComponentInterface<'Employee.Compensation'> { - employeeId: string +const FLSA_STATUS_LABELS: Record = { + 'Commission Only Exempt': 'Commission Only/No Overtime', + 'Commission Only Nonexempt': 'Commission Only/Eligible for overtime', + Exempt: 'Salary/No overtime', + Nonexempt: 'Paid by the hour', + Owner: "Owner's draw", + 'Salaried Nonexempt': 'Salary/Eligible for overtime', +} + +const COLUMN_LABELS = { + effectiveDate: 'Effective date', + employeeType: 'Employee type', + wage: 'Wage', + jobTitle: 'Job title', +} + +export interface CompensationHistoryProps { + employeeId?: string onBack?: () => void } -export function CompensationHistory({ dictionary, ...props }: CompensationHistoryProps) { - useComponentDictionary('Employee.Compensation', dictionary) +export function CompensationHistory(props: CompensationHistoryProps = {}) { + const outletContext = useOutletContext<{ entities: EntityIds } | null>() + const employeeId = props.employeeId ?? outletContext?.entities.employeeId ?? '' + + if (!employeeId) { + return ( +

+ No employee ID is configured. Set VITE_EMPLOYEE_ID or pick an employee from the + entity panel to view this prototype. +

+ ) + } + return ( - - + + ) } -function Root({ employeeId, onBack }: Omit) { - useI18n('Employee.Compensation') - const { t } = useTranslation('Employee.Compensation') +function Root({ employeeId, onBack }: { employeeId: string; onBack?: () => void }) { const Components = useComponentContext() const jobsQuery = useJobsAndCompensationsGetJobs( @@ -53,7 +78,7 @@ function Root({ employeeId, onBack }: Omit {jobs.length === 0 ? ( - {t('history.emptyState')} + No compensation history yet. ) : jobs.length === 1 && jobs[0] ? ( ) : ( @@ -62,7 +87,7 @@ function Root({ employeeId, onBack }: Omit - {t('backCta')} + Back )} @@ -76,8 +101,12 @@ function getJobTitle(job: Job): string { return currentComp?.title ?? job.title ?? '' } +function formatFlsaStatus(status: string | undefined): string { + if (!status) return '' + return FLSA_STATUS_LABELS[status] ?? status +} + function JobCompensationHistory({ job }: { job: Job }) { - const { t } = useTranslation('Employee.Compensation') const Components = useComponentContext() const formatCompensationRate = useFormatCompensationRate() @@ -92,20 +121,17 @@ function JobCompensationHistory({ job }: { job: Job }) { columns: [ { key: 'effectiveDate', - title: t('history.effectiveDateColumn'), + title: COLUMN_LABELS.effectiveDate, render: compensation => formatDateLongWithYear(compensation.effectiveDate), }, { key: 'flsaStatus', - title: t('history.employeeTypeColumn'), - render: compensation => - compensation.flsaStatus !== undefined - ? t(`flsaStatusLabels.${compensation.flsaStatus}`) - : '', + title: COLUMN_LABELS.employeeType, + render: compensation => formatFlsaStatus(compensation.flsaStatus), }, { key: 'rate', - title: t('history.wageColumn'), + title: COLUMN_LABELS.wage, render: compensation => { const rate = Number(compensation.rate) if (!compensation.paymentUnit || Number.isNaN(rate)) return '' @@ -118,7 +144,7 @@ function JobCompensationHistory({ job }: { job: Job }) { return ( {jobTitle} - + ) } @@ -129,7 +155,6 @@ type CombinedRow = { } function CombinedCompensationHistory({ jobs }: { jobs: Job[] }) { - const { t } = useTranslation('Employee.Compensation') const Components = useComponentContext() const formatCompensationRate = useFormatCompensationRate() const [selectedJobUuid, setSelectedJobUuid] = useState('all') @@ -147,7 +172,7 @@ function CombinedCompensationHistory({ jobs }: { jobs: Job[] }) { ) const options = [ - { value: 'all', label: t('history.allJobsOption') }, + { value: 'all', label: 'All jobs' }, ...jobs.map(job => ({ value: job.uuid, label: getJobTitle(job) })), ] @@ -156,25 +181,22 @@ function CombinedCompensationHistory({ jobs }: { jobs: Job[] }) { columns: [ { key: 'effectiveDate', - title: t('history.effectiveDateColumn'), + title: COLUMN_LABELS.effectiveDate, render: row => formatDateLongWithYear(row.compensation.effectiveDate), }, { key: 'title', - title: t('history.jobTitleColumn'), + title: COLUMN_LABELS.jobTitle, render: row => row.compensation.title ?? row.job.title ?? '', }, { key: 'flsaStatus', - title: t('history.employeeTypeColumn'), - render: row => - row.compensation.flsaStatus !== undefined - ? t(`flsaStatusLabels.${row.compensation.flsaStatus}`) - : '', + title: COLUMN_LABELS.employeeType, + render: row => formatFlsaStatus(row.compensation.flsaStatus), }, { key: 'rate', - title: t('history.wageColumn'), + title: COLUMN_LABELS.wage, render: row => { const rate = Number(row.compensation.rate) if (!row.compensation.paymentUnit || Number.isNaN(rate)) return '' @@ -193,10 +215,10 @@ function CombinedCompensationHistory({ jobs }: { jobs: Job[] }) { alignItems={isDesktop ? 'center' : 'stretch'} gap={isDesktop ? 0 : 16} > - {t('history.heading')} + Compensation history
{ @@ -206,7 +228,7 @@ function CombinedCompensationHistory({ jobs }: { jobs: Job[] }) { />
- + ) diff --git a/sdk-app/src/design/registry.ts b/sdk-app/src/design/registry.ts index d16e26abb..086fd1cde 100644 --- a/sdk-app/src/design/registry.ts +++ b/sdk-app/src/design/registry.ts @@ -33,6 +33,13 @@ export const categorizedRegistry: CategorizedRegistry = { description: 'The contractor-facing onboarding experience after receiving an invite link.', }, ], - Employees: [], + Employees: [ + { + name: 'Compensation History', + path: '/design/employee-compensation-history', + description: + 'A read-only view of an employee’s compensation history across all of their jobs, with a job filter for multi-job employees.', + }, + ], Payroll: [], } diff --git a/sdk-app/src/main.tsx b/sdk-app/src/main.tsx index a16cdc2b4..38ec74bef 100644 --- a/sdk-app/src/main.tsx +++ b/sdk-app/src/main.tsx @@ -14,6 +14,7 @@ import { ContractorDismiss } from './design/prototypes/contractor-management/con import { ContractorRehire } from './design/prototypes/contractor-management/contractor-list/ContractorRehire' import { AddContractor } from './design/prototypes/contractor-management/contractor-list/AddContractor' import { ContractorSelfOnboarding } from './design/prototypes/contractor-management/self-onboarding' +import { CompensationHistory } from './design/prototypes/employee-management/CompensationHistory' import './app.scss' import '@/styles/sdk.scss' @@ -43,6 +44,7 @@ const router = createBrowserRouter([ ], }, { path: 'contractor-self-onboarding', element: }, + { path: 'employee-compensation-history', element: }, ], }, ], diff --git a/src/components/Employee/Compensation/management/CompensationHistory/index.ts b/src/components/Employee/Compensation/management/CompensationHistory/index.ts deleted file mode 100644 index 0163e0256..000000000 --- a/src/components/Employee/Compensation/management/CompensationHistory/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { CompensationHistory } from './CompensationHistory' -export type { CompensationHistoryProps } from './CompensationHistory' diff --git a/src/components/Employee/Compensation/management/index.ts b/src/components/Employee/Compensation/management/index.ts index f857add35..480db1f57 100644 --- a/src/components/Employee/Compensation/management/index.ts +++ b/src/components/Employee/Compensation/management/index.ts @@ -3,4 +3,3 @@ export type { EditCompensationProps as ManagementEditCompensationProps } from '. export { EditPendingCompensation as ManagementEditPendingCompensation } from './EditPendingCompensation/EditPendingCompensation' export type { EditPendingCompensationProps as ManagementEditPendingCompensationProps } from './EditPendingCompensation/EditPendingCompensation' export * from './AddAnotherJob/AddAnotherJob' -export * from './CompensationHistory' diff --git a/src/components/Employee/Dashboard/Dashboard.tsx b/src/components/Employee/Dashboard/Dashboard.tsx index ea5daf78d..973aa0190 100644 --- a/src/components/Employee/Dashboard/Dashboard.tsx +++ b/src/components/Employee/Dashboard/Dashboard.tsx @@ -66,10 +66,6 @@ function DashboardRoot({ onEvent(componentEvents.EMPLOYEE_JOB_ADD_ANOTHER, { employeeId }) }, [onEvent, employeeId]) - const handleViewCompensationHistory = useCallback(() => { - onEvent(componentEvents.EMPLOYEE_COMPENSATION_HISTORY_VIEW, { employeeId }) - }, [onEvent, employeeId]) - const handleAddDeduction = useCallback(() => { onEvent(componentEvents.EMPLOYEE_DEDUCTION_ADD, { employeeId }) }, [onEvent, employeeId]) @@ -160,7 +156,6 @@ function DashboardRoot({ onEditCompensation={handleEditCompensation} onAddJob={handleAddJob} onAddAnotherJob={handleAddAnotherJob} - onViewHistory={handleViewCompensationHistory} onAddDeduction={handleAddDeduction} onEditDeduction={handleEditDeduction} /> diff --git a/src/components/Employee/Dashboard/DashboardComponents.tsx b/src/components/Employee/Dashboard/DashboardComponents.tsx index fca7b2f1a..7f1765b80 100644 --- a/src/components/Employee/Dashboard/DashboardComponents.tsx +++ b/src/components/Employee/Dashboard/DashboardComponents.tsx @@ -17,7 +17,6 @@ import { } from '@/components/Employee/Compensation/management' import { useDeductionsList } from '@/components/Employee/Deductions/shared' import { AddAnotherJob } from '@/components/Employee/Compensation/management/AddAnotherJob/AddAnotherJob' -import { CompensationHistory } from '@/components/Employee/Compensation/management/CompensationHistory' import { EditCompensation } from '@/components/Employee/Compensation/onboarding/EditCompensation/EditCompensation' import { useFlow, type FlowContextInterface } from '@/components/Flow/useFlow' import { useComponentContext } from '@/contexts/ComponentAdapter/useComponentContext' @@ -240,15 +239,3 @@ export function AddAnotherJobContextual() { /> ) } - -export function CompensationHistoryContextual() { - const { employeeId, onEvent } = useFlow() - return ( - { - onEvent(componentEvents.CANCEL) - }} - /> - ) -} diff --git a/src/components/Employee/Dashboard/JobAndPayView.tsx b/src/components/Employee/Dashboard/JobAndPayView.tsx index 982036d8b..ab88d3797 100644 --- a/src/components/Employee/Dashboard/JobAndPayView.tsx +++ b/src/components/Employee/Dashboard/JobAndPayView.tsx @@ -59,7 +59,6 @@ export interface JobAndPayViewProps { onEditCompensation?: (job: Job) => void onAddJob?: () => void onAddAnotherJob?: () => void - onViewHistory?: () => void onAddDeduction?: () => void onEditDeduction?: (deduction: Garnishment) => void } @@ -70,7 +69,6 @@ export function JobAndPayView({ onEditCompensation, onAddJob, onAddAnotherJob, - onViewHistory, onAddDeduction, onEditDeduction, }: JobAndPayViewProps) { @@ -619,21 +617,14 @@ export function JobAndPayView({ /> } footer={ - !isCompensationCardLoading && jobs.length > 0 ? ( - - {canAddAnotherJob && ( - } - > - {t('jobAndPay.compensation.addAnotherJobCta')} - - )} - - {t('jobAndPay.compensation.viewHistoryCta')} - - + !isCompensationCardLoading && canAddAnotherJob ? ( + } + > + {t('jobAndPay.compensation.addAnotherJobCta')} + ) : undefined } > diff --git a/src/components/Employee/Dashboard/dashboardStateMachine.ts b/src/components/Employee/Dashboard/dashboardStateMachine.ts index 09827a273..519aa5b47 100644 --- a/src/components/Employee/Dashboard/dashboardStateMachine.ts +++ b/src/components/Employee/Dashboard/dashboardStateMachine.ts @@ -16,7 +16,6 @@ import { AddJobContextual, EditCompensationContextual, AddAnotherJobContextual, - CompensationHistoryContextual, type DashboardContextInterface, } from './DashboardComponents' import { componentEvents } from '@/shared/constants' @@ -183,17 +182,6 @@ export const dashboardStateMachine = { }), ), ), - transition( - componentEvents.EMPLOYEE_COMPENSATION_HISTORY_VIEW, - 'compensationHistory', - reduce( - (ctx: DashboardContextInterface): DashboardContextInterface => ({ - ...ctx, - component: CompensationHistoryContextual, - successAlert: null, - }), - ), - ), transition( componentEvents.EMPLOYEE_BANK_ACCOUNT_DELETED, 'index', @@ -326,7 +314,4 @@ export const dashboardStateMachine = { ), transition(componentEvents.CANCEL, 'index', returnToIndex), ), - compensationHistory: state( - transition(componentEvents.CANCEL, 'index', returnToIndex), - ), } diff --git a/src/i18n/en/Employee.Compensation.json b/src/i18n/en/Employee.Compensation.json index 23d589d73..1b02aac78 100644 --- a/src/i18n/en/Employee.Compensation.json +++ b/src/i18n/en/Employee.Compensation.json @@ -29,18 +29,6 @@ "Salaried Nonexempt": "Salary/Eligible for overtime" }, "hamburgerTitle": "Job actions", - "history": { - "effectiveDateColumn": "Effective date", - "employeeTypeColumn": "Employee type", - "wageColumn": "Wage", - "tableLabel": "Compensation history for {{jobTitle}}", - "jobTitleColumn": "Job title", - "combinedTableLabel": "Compensation history across all jobs", - "heading": "Compensation history", - "jobFilterLabel": "Filter by job", - "allJobsOption": "All jobs", - "emptyState": "No compensation history yet." - }, "jobTitle": "Job Title", "paymentUnitDescription": "The period over which the compensation amount is tracked (e.g., hourly, daily, weekly, monthly, annually).", "paymentUnitLabel": "Wage frequency", diff --git a/src/i18n/en/Employee.Dashboard.json b/src/i18n/en/Employee.Dashboard.json index 79e264712..9652725dc 100644 --- a/src/i18n/en/Employee.Dashboard.json +++ b/src/i18n/en/Employee.Dashboard.json @@ -44,7 +44,6 @@ "effectiveDate": "Effective date", "addJobCta": "Add job", "addAnotherJobCta": "Add another job", - "viewHistoryCta": "View history", "tableLabel": "List of jobs", "hamburgerTitle": "Job actions", "editJobCta": "Edit", diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 20938c404..4541dcf64 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -38,7 +38,6 @@ export const employeeEvents = { EMPLOYEE_COMPENSATION_CANCEL: 'employee/compensations/cancel', EMPLOYEE_COMPENSATION_CHANGE_CANCELLED: 'employee/compensations/changeCancelled', EMPLOYEE_COMPENSATION_RETURN_TO_LIST: 'employee/compensations/returnToList', - EMPLOYEE_COMPENSATION_HISTORY_VIEW: 'employee/compensations/historyView', EMPLOYEE_JOB_ADD: 'employee/job/add', EMPLOYEE_JOB_ADD_ANOTHER: 'employee/job/addAnother', EMPLOYEE_JOB_EDIT: 'employee/job/edit', diff --git a/src/types/i18next.d.ts b/src/types/i18next.d.ts index 5d75e27c8..1efaa74e4 100644 --- a/src/types/i18next.d.ts +++ b/src/types/i18next.d.ts @@ -1361,18 +1361,6 @@ export interface EmployeeCompensation{ "Salaried Nonexempt":string; }; "hamburgerTitle":string; -"history":{ -"effectiveDateColumn":string; -"employeeTypeColumn":string; -"wageColumn":string; -"tableLabel":string; -"jobTitleColumn":string; -"combinedTableLabel":string; -"heading":string; -"jobFilterLabel":string; -"allJobsOption":string; -"emptyState":string; -}; "jobTitle":string; "paymentUnitDescription":string; "paymentUnitLabel":string; @@ -1473,7 +1461,6 @@ export interface EmployeeDashboard{ "effectiveDate":string; "addJobCta":string; "addAnotherJobCta":string; -"viewHistoryCta":string; "tableLabel":string; "hamburgerTitle":string; "editJobCta":string; From 55b999e681b43099fa697d7d21e41dcbac631a18 Mon Sep 17 00:00:00 2001 From: Aaron Lee Date: Tue, 2 Jun 2026 12:17:22 -0400 Subject: [PATCH 05/19] feat(sdk-app): add component-state prototype viewer for design app MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a Storybook-lite viewer for browsing prototype component states with mocked data, alongside the existing live-data prototype view. Each prototype declares its own `states.tsx` listing components and configurations (slug, name, MSW handlers, render fn). The new ComponentStatesPage renders a right-rail sidebar of those configurations and uses MSW (browser worker) to intercept SDK API calls so each configuration renders with deterministic mock data. The browser worker lazy-starts on first selection and uses `onUnhandledRequest: 'bypass'` so live routes are unaffected. CompensationHistory is the first migrated prototype. Its sidebar entry now nests two children — Prototype (live) and Component states — and the latter exposes Single job, Multiple jobs, Mixed wages, and Empty configurations built from the shared `buildJob` factory primitive. Empty states render via DataView's emptyState prop instead of bare text. Misc: - DesignLayout becomes a flex shell with a portal-based right rail so the component-states sidebar can sit flush against the viewport edge. - Left sidebar: tighter active treatment (color only, no border/bg), uppercase tiny category labels, and a font-weight bump on top-level items so nested children read as a clear visual hierarchy. Co-Authored-By: Claude Opus 4.7 (1M context) --- package.json | 3 +- sdk-app/src/Sidebar.module.scss | 90 +++---- sdk-app/src/Sidebar.tsx | 53 ++-- sdk-app/src/design/DesignLayout.module.scss | 30 ++- sdk-app/src/design/DesignLayout.tsx | 11 +- .../ComponentStatesPage.module.scss | 4 + .../design/prototypes/ComponentStatesPage.tsx | 59 +++++ .../ComponentStatesSidebar.module.scss | 56 +++++ .../prototypes/ComponentStatesSidebar.tsx | 28 +++ .../src/design/prototypes/MockedRender.tsx | 36 +++ .../CompensationHistory.test.tsx | 20 +- .../CompensationHistoryComponent.tsx | 231 ++++++++++++++++++ .../CompensationHistory/index.tsx | 226 +---------------- .../CompensationHistory/states.tsx | 113 +++++++++ .../src/design/prototypes/prototypeTypes.ts | 25 ++ sdk-app/src/design/registry.ts | 13 + sdk-app/src/main.tsx | 25 +- sdk-app/src/mocks/browser.ts | 3 + sdk-app/src/mocks/useMockHandlers.ts | 54 ++++ 19 files changed, 771 insertions(+), 309 deletions(-) create mode 100644 sdk-app/src/design/prototypes/ComponentStatesPage.module.scss create mode 100644 sdk-app/src/design/prototypes/ComponentStatesPage.tsx create mode 100644 sdk-app/src/design/prototypes/ComponentStatesSidebar.module.scss create mode 100644 sdk-app/src/design/prototypes/ComponentStatesSidebar.tsx create mode 100644 sdk-app/src/design/prototypes/MockedRender.tsx create mode 100644 sdk-app/src/design/prototypes/employee-management/CompensationHistory/CompensationHistoryComponent.tsx create mode 100644 sdk-app/src/design/prototypes/employee-management/CompensationHistory/states.tsx create mode 100644 sdk-app/src/design/prototypes/prototypeTypes.ts create mode 100644 sdk-app/src/mocks/browser.ts create mode 100644 sdk-app/src/mocks/useMockHandlers.ts diff --git a/package.json b/package.json index 23700735f..1a6890f70 100644 --- a/package.json +++ b/package.json @@ -189,7 +189,8 @@ "msw": { "workerDirectory": [ "e2e", - "e2e/public" + "e2e/public", + "sdk-app/public" ] } } diff --git a/sdk-app/src/Sidebar.module.scss b/sdk-app/src/Sidebar.module.scss index b57b91ff0..5aa1dceb8 100644 --- a/sdk-app/src/Sidebar.module.scss +++ b/sdk-app/src/Sidebar.module.scss @@ -51,48 +51,6 @@ border-bottom: 0.0625rem solid var(--color-border); } -.shortcutHint { - display: flex; - align-items: center; - gap: 0.375rem; - width: 100%; - margin-bottom: 0.5rem; - padding: 0.25rem 0.5rem; - border: none; - border-radius: 0.25rem; - background: transparent; - font: inherit; - font-size: 0.6875rem; - text-align: left; - color: var(--color-text-muted); - cursor: pointer; - user-select: none; - - &:hover { - color: var(--color-text); - background: var(--color-hover-bg); - } - - &:focus-visible { - outline: 0.125rem solid var(--color-active); - outline-offset: 0.125rem; - } -} - -.shortcutHintKey { - display: inline-flex; - align-items: center; - justify-content: center; - min-width: 1.125rem; - padding: 0 0.3125rem; - border: 0.0625rem solid var(--color-border); - border-radius: 0.1875rem; - background: var(--color-badge-bg); - font-family: monospace; - font-size: 0.625rem; - color: var(--color-text); -} - .searchRow { display: flex; align-items: stretch; @@ -158,10 +116,8 @@ justify-content: space-between; padding: 0.5rem 1rem; cursor: pointer; - font-weight: 500; - font-size: 0.8rem; - color: var(--color-text-muted); user-select: none; + &:hover { color: var(--color-text); } @@ -173,14 +129,17 @@ border-radius: 0.625rem; font-size: 0.6875rem; font-weight: 500; + color: var(--color-text-muted); } .categoryTitle { display: inline-flex; align-items: center; gap: 0.375rem; - font-weight: 500; - font-size: 0.8rem; + font-size: 0.6875rem; + font-weight: 600; + letter-spacing: 0.05em; + text-transform: uppercase; color: var(--color-text-muted); } @@ -197,25 +156,48 @@ list-style: none; } -.item a { +.item > a { display: block; - padding: 0.375rem 1rem 0.375rem 1.75rem; + padding: 0.375rem 1rem 0.375rem 1.5rem; color: var(--color-text); text-decoration: none; font-size: 0.8125rem; - border-left: 0.1875rem solid transparent; + font-weight: 500; transition: background 0.1s, - border-color 0.1s; + color 0.1s; &:hover { background: var(--color-hover-bg); } &:global(.active) { - background: var(--color-active-bg); - border-left-color: var(--color-active); color: var(--color-active); - font-weight: 500; } } + +.subItems { + list-style: none; + margin: 0; + padding: 0; +} + +.subItem a { + display: block; + padding: 0.3125rem 1rem 0.3125rem 2.25rem; + color: var(--color-text-muted); + text-decoration: none; + font-size: 0.75rem; + transition: + background 0.1s, + color 0.1s; + + &:hover { + background: var(--color-hover-bg); + } + + &:global(.active) { + color: var(--color-active); + } +} + diff --git a/sdk-app/src/Sidebar.tsx b/sdk-app/src/Sidebar.tsx index 7fabb4ecb..1a15c5589 100644 --- a/sdk-app/src/Sidebar.tsx +++ b/sdk-app/src/Sidebar.tsx @@ -1,5 +1,5 @@ import { useState, useMemo } from 'react' -import { NavLink } from 'react-router-dom' +import { NavLink, useLocation } from 'react-router-dom' import { categorizedRegistry as previewRegistry, CATEGORIES as PREVIEW_CATEGORIES, @@ -21,6 +21,16 @@ interface SidebarProps { onShowShortcuts: () => void } +interface SidebarItem { + name: string + path?: string + children?: SidebarItem[] +} + +function isUnder(pathname: string, target: string): boolean { + return pathname === target || pathname.startsWith(`${target}/`) +} + function CategorySection({ category, items, @@ -28,16 +38,20 @@ function CategorySection({ mode, }: { category: string - items: { name: string; path?: string }[] + items: SidebarItem[] searchQuery: string mode: AppMode }) { const [collapsed, setCollapsed] = useState(false) + const { pathname } = useLocation() const filteredItems = useMemo(() => { if (!searchQuery) return items const q = searchQuery.toLowerCase() - return items.filter(item => item.name.toLowerCase().includes(q)) + return items.filter(item => { + if (item.name.toLowerCase().includes(q)) return true + return item.children?.some(child => child.name.toLowerCase().includes(q)) ?? false + }) }, [items, searchQuery]) if (searchQuery && filteredItems.length === 0) return null @@ -64,7 +78,6 @@ function CategorySection({ /> {displayCategory} - {filteredItems.length} {!collapsed && ( @@ -72,9 +85,22 @@ function CategorySection({ {filteredItems.map(item => { const to = mode === 'design' && item.path ? item.path : `/${category.toLowerCase()}/${item.name}` + const showChildren = + !!item.children?.length && !!item.path && isUnder(pathname, item.path) return (
  • - {item.name} + + {item.name} + + {showChildren && item.children && ( +
      + {item.children.map(child => ( +
    • + {child.name} +
    • + ))} +
    + )}
  • ) })} @@ -84,14 +110,7 @@ function CategorySection({ ) } -export function Sidebar({ - mode, - searchQuery, - onSearchChange, - isOpen, - onToggle, - onShowShortcuts, -}: SidebarProps) { +export function Sidebar({ mode, searchQuery, onSearchChange, isOpen, onToggle }: SidebarProps) { const placeholder = mode === 'design' ? 'Search prototypes...' : 'Search components...' if (!isOpen) { @@ -113,14 +132,6 @@ export function Sidebar({ return (