diff --git a/src/components/Common/DataView/DataCards/DataCards.module.scss b/src/components/Common/DataView/DataCards/DataCards.module.scss index 673fb1493..a539d10f8 100644 --- a/src/components/Common/DataView/DataCards/DataCards.module.scss +++ b/src/components/Common/DataView/DataCards/DataCards.module.scss @@ -7,9 +7,29 @@ h5.columnTitle { .columnData { width: 100%; + display: flex; + flex-direction: column; + gap: 0.125rem; color: var(--g-colorBodyContent); } +.columnPrimary { + color: inherit; +} + +.columnSecondary { + color: var(--g-colorBodySubContent); + font-size: var(--g-fontSizeSmall); +} + .footerItem { width: 100%; + display: flex; + flex-direction: column; + gap: toRem(2); +} + +.footerSecondary { + color: var(--g-colorBodySubContent); + font-size: var(--g-fontSizeSmall); } diff --git a/src/components/Common/DataView/DataCards/DataCards.test.tsx b/src/components/Common/DataView/DataCards/DataCards.test.tsx index c01602e45..8f60c9a91 100644 --- a/src/components/Common/DataView/DataCards/DataCards.test.tsx +++ b/src/components/Common/DataView/DataCards/DataCards.test.tsx @@ -9,17 +9,18 @@ type MockData = { id: number name: string age: number + department: string } // Sample test data const testData: MockData[] = [ - { id: 1, name: 'Alice', age: 25 }, - { id: 2, name: 'Bob', age: 30 }, + { id: 1, name: 'Alice', age: 25, department: 'Engineering' }, + { id: 2, name: 'Bob', age: 30, department: 'Operations' }, ] // Sample columns const testColumns = [ - { key: 'name', title: 'Name' }, + { key: 'name', title: 'Name', secondaryText: 'department' }, { key: 'age', title: 'Age' }, ] as const @@ -40,6 +41,13 @@ describe('DataCards', () => { expect(screen.getByText('Bob')).toBeInTheDocument() }) + test('should render secondary text when provided', () => { + renderWithProviders() + + expect(screen.getByText('Engineering')).toBeInTheDocument() + expect(screen.getByText('Operations')).toBeInTheDocument() + }) + test('should render the component with column headers', () => { renderWithProviders() diff --git a/src/components/Common/DataView/DataCards/DataCards.tsx b/src/components/Common/DataView/DataCards/DataCards.tsx index c578ea7cc..e7795ee94 100644 --- a/src/components/Common/DataView/DataCards/DataCards.tsx +++ b/src/components/Common/DataView/DataCards/DataCards.tsx @@ -1,3 +1,5 @@ +import { getColumnContent } from '../getColumnContent' +import { getFooterContent } from '../getFooterContent' import styles from './DataCards.module.scss' import type { useDataViewPropReturn } from '@/components/Common/DataView/useDataView' import { Flex } from '@/components/Common/Flex/Flex' @@ -40,15 +42,20 @@ export const DataCards = ({ : undefined } > - {columns.map((column, index) => ( - - {column.title &&
{column.title}
} -
- {' '} - {column.render ? column.render(item) : String(item[column.key as keyof T])} -
-
- ))} + {columns.map((column, index) => { + const { primary, secondary } = getColumnContent(item, column) + return ( + + {column.title &&
{column.title}
} +
+
{primary}
+ {secondary !== undefined && ( +
{secondary}
+ )} +
+
+ ) + })} ))} @@ -59,11 +66,17 @@ export const DataCards = ({ const footerContent = footer() // Footer content is always an object with column keys - return Object.entries(footerContent).map(([key, content]) => ( -
- {content} -
- )) + return Object.entries(footerContent).map(([key, content]) => { + const { primary, secondary } = getFooterContent(content) + return ( +
+
{primary}
+ {secondary !== undefined && ( +
{secondary}
+ )} +
+ ) + }) })()} diff --git a/src/components/Common/DataView/DataTable/DataTable.module.scss b/src/components/Common/DataView/DataTable/DataTable.module.scss new file mode 100644 index 000000000..68eeb671a --- /dev/null +++ b/src/components/Common/DataView/DataTable/DataTable.module.scss @@ -0,0 +1,56 @@ +.cellWrapper { + width: 100%; + text-align: left; +} + +.cellWrapper[data-align='center'] { + text-align: center; +} + +.cellWrapper[data-align='right'] { + text-align: right; +} + +.cellContent { + display: flex; + flex-direction: column; + gap: toRem(2); +} + +.cellSecondary { + color: var(--g-colorBodySubContent); + font-weight: var(--g-fontWeightRegular); +} + +.headerCell { + text-align: left; +} + +.headerCell[data-align='center'] { + text-align: center; +} + +.headerCell[data-align='right'] { + text-align: right; +} + +.footerCell { + width: 100%; + text-align: left; + display: flex; + flex-direction: column; + gap: toRem(2); +} + +.footerCell[data-align='center'] { + text-align: center; +} + +.footerCell[data-align='right'] { + text-align: right; +} + +.footerSecondary { + color: var(--g-colorBodySubContent); + font-weight: var(--g-fontWeightRegular); +} diff --git a/src/components/Common/DataView/DataTable/DataTable.test.tsx b/src/components/Common/DataView/DataTable/DataTable.test.tsx index 0a806eb8f..83722a662 100644 --- a/src/components/Common/DataView/DataTable/DataTable.test.tsx +++ b/src/components/Common/DataView/DataTable/DataTable.test.tsx @@ -12,12 +12,13 @@ type MockData = { id: number name: string age: number + department: string } // Sample test data const testData: MockData[] = [ - { id: 1, name: 'Alice', age: 25 }, - { id: 2, name: 'Bob', age: 30 }, + { id: 1, name: 'Alice', age: 25, department: 'Engineering' }, + { id: 2, name: 'Bob', age: 30, department: 'Operations' }, ] // Sample columns @@ -25,11 +26,13 @@ const testColumns: useDataViewPropReturn['columns'] = [ { key: 'name', title: 'Name', + secondaryText: 'department', render: (item: MockData) => item.name, }, { key: 'age', title: 'Age', + align: 'right', render: (item: MockData) => item.age.toString(), }, ] @@ -67,6 +70,17 @@ describe('DataTable Component', () => { expect(screen.getByText('Bob')).toBeInTheDocument() }) + test('should render secondary text when provided', () => { + renderTable({ + data: testData, + columns: testColumns, + label: 'Test Table', + }) + + expect(screen.getByText('Engineering')).toBeInTheDocument() + expect(screen.getByText('Operations')).toBeInTheDocument() + }) + test('should render checkboxes and call onSelect when clicked', async () => { const onSelectMock = vi.fn() renderTable({ @@ -119,6 +133,16 @@ describe('DataTable Component', () => { expect(screen.getByText('55')).toBeInTheDocument() }) + test('should apply alignment when column specifies it', () => { + const { container } = renderTable({ + data: testData, + columns: testColumns, + label: 'Aligned Table', + }) + + expect(container.querySelectorAll('[data-align="right"]').length).toBeGreaterThan(0) + }) + describe('accessibility', () => { it('should not have any accessibility violations - empty table', async () => { const { container } = renderTable({ data: [], columns: [], label: 'Test Table' }) diff --git a/src/components/Common/DataView/DataTable/DataTable.tsx b/src/components/Common/DataView/DataTable/DataTable.tsx index 5493a50bb..fcaaec976 100644 --- a/src/components/Common/DataView/DataTable/DataTable.tsx +++ b/src/components/Common/DataView/DataTable/DataTable.tsx @@ -2,6 +2,9 @@ import { useTranslation } from 'react-i18next' import type { useDataViewPropReturn } from '../useDataView' import type { TableData, TableRow, TableProps } from '../../UI/Table/TableTypes' import { VisuallyHidden } from '../../VisuallyHidden' +import { getColumnContent } from '../getColumnContent' +import { getFooterContent } from '../getFooterContent' +import styles from './DataTable.module.scss' import { useComponentContext } from '@/contexts/ComponentAdapter/useComponentContext' export type DataTableProps = { @@ -15,22 +18,6 @@ export type DataTableProps = { variant?: TableProps['variant'] } -function getCellContent( - item: T, - column: { key?: string | keyof T; render?: (item: T) => React.ReactNode }, -) { - if (column.render) { - return column.render(item) - } - - if (column.key) { - const key = column.key as keyof T - return String(item[key] ?? '') - } - - return '' -} - export const DataTable = ({ label, data, @@ -53,10 +40,17 @@ export const DataTable = ({ }, ] : []), - ...columns.map((column, index) => ({ - key: typeof column.key === 'string' ? column.key : `header-${index}`, - content: column.title, - })), + ...columns.map((column, index) => { + const alignment = column.align ?? 'left' + return { + key: typeof column.key === 'string' ? column.key : `header-${index}`, + content: ( +
+ {column.title} +
+ ), + } + }), ...(itemMenu ? [ { @@ -86,9 +80,25 @@ export const DataTable = ({ ] : []), ...columns.map((column, colIndex) => { + const alignment = column.align ?? 'left' + const { primary, secondary } = getColumnContent(item, column) + const cellContent = + secondary !== undefined ? ( +
+
{primary}
+
{secondary}
+
+ ) : ( + primary + ) + return { key: typeof column.key === 'string' ? column.key : `cell-${colIndex}`, - content: getCellContent(item, column), + content: ( +
+ {cellContent} +
+ ), } }), ...(itemMenu @@ -124,9 +134,20 @@ export const DataTable = ({ // Add data column footers columns.forEach((column, index) => { const columnKey = typeof column.key === 'string' ? column.key : `column-${index}` + const alignment = column.align ?? 'left' + const footerValue = footerContent[columnKey] + const { primary: footerPrimary, secondary: footerSecondary } = getFooterContent(footerValue) + footerCells.push({ key: `footer-${columnKey}`, - content: footerContent[columnKey] || '', + content: ( +
+
{footerPrimary}
+ {footerSecondary !== undefined && ( +
{footerSecondary}
+ )} +
+ ), }) }) @@ -134,7 +155,7 @@ export const DataTable = ({ if (itemMenu) { footerCells.push({ key: 'footer-actions', - content: '', + content:
, }) } diff --git a/src/components/Common/DataView/DataView.stories.tsx b/src/components/Common/DataView/DataView.stories.tsx index 0512d88d2..82116a86d 100644 --- a/src/components/Common/DataView/DataView.stories.tsx +++ b/src/components/Common/DataView/DataView.stories.tsx @@ -122,6 +122,23 @@ const compensationData = [ }, ] +const departments = [ + 'Operations', + 'Sales', + 'Engineering', + 'Finance', + 'Marketing', + 'Customer Success', + 'People Ops', + 'Product', +] + +const compensationDataWithDetails = compensationData.map((item, index) => ({ + ...item, + department: departments[index % departments.length], + employeeId: `EMP-${(index + 1).toString().padStart(3, '0')}`, +})) + export const DataViewDefault = () => { const { ...dataProps } = useDataView({ data: compensationData, @@ -156,6 +173,20 @@ export const DataViewSelectable = () => { return } +export const DataViewWithSecondaryText = () => { + const { ...dataProps } = useDataView({ + data: compensationDataWithDetails, + columns: [ + { key: 'jobTitle', title: 'Job Title', secondaryText: 'department' }, + { key: 'payType', title: 'Pay Type' }, + { key: 'amount', title: 'Amount', secondaryText: 'employeeId' }, + { key: 'payTimePeriod', title: 'Pay Time Period' }, + ], + }) + + return +} + export const DataViewWithMenu = () => { const { ...dataProps } = useDataView({ data: compensationData, @@ -165,7 +196,7 @@ export const DataViewWithMenu = () => { { key: 'amount', title: 'Amount' }, { key: 'payTimePeriod', title: 'Pay Time Period' }, ], - itemMenu: item => { + itemMenu: () => { return ( { { key: 'amount', title: 'Amount' }, { key: 'payTimePeriod', title: 'Pay Time Period' }, ], - itemMenu: item => { + itemMenu: () => { return ( = { + primary: ReactNode + secondary?: ReactNode +} + +const normalizeNode = (value: unknown): ReactNode => { + if (value === null || value === undefined) { + return '' + } + return value as ReactNode +} + +export const getColumnContent = (item: T, column: DataViewColumn): ColumnContent => { + const primary = column.render + ? column.render(item) + : column.key + ? normalizeNode(item[column.key as keyof T]) + : '' + + const secondary = column.secondaryRender + ? column.secondaryRender(item) + : column.secondaryText + ? normalizeNode(item[column.secondaryText]) + : undefined + + return { + primary: normalizeNode(primary), + secondary: + secondary === undefined || secondary === null || secondary === '' + ? undefined + : normalizeNode(secondary), + } +} diff --git a/src/components/Common/DataView/getFooterContent.ts b/src/components/Common/DataView/getFooterContent.ts new file mode 100644 index 000000000..b6fddb3bb --- /dev/null +++ b/src/components/Common/DataView/getFooterContent.ts @@ -0,0 +1,25 @@ +import type { ReactNode } from 'react' +import type { DataViewFooterCell } from './useDataView' + +type StructuredFooter = { primary: ReactNode; secondary?: ReactNode } + +const isStructuredFooter = (value: DataViewFooterCell): value is StructuredFooter => { + return typeof value === 'object' && value !== null && 'primary' in value +} + +export const getFooterContent = (value?: DataViewFooterCell): StructuredFooter => { + if (isStructuredFooter(value)) { + return { + primary: value.primary, + secondary: value.secondary, + } + } + + if (value === null || value === undefined) { + return { primary: '' } + } + + return { + primary: value, + } +} diff --git a/src/components/Common/DataView/useDataView.ts b/src/components/Common/DataView/useDataView.ts index bb9edc95e..e237ebd08 100644 --- a/src/components/Common/DataView/useDataView.ts +++ b/src/components/Common/DataView/useDataView.ts @@ -1,17 +1,25 @@ import { useMemo } from 'react' +import type { ReactNode } from 'react' import type { PaginationControlProps } from '@/components/Common/PaginationControl/PaginationControlTypes' -type DataViewColumn = - | { +type DataViewColumnBase = { + title: string | React.ReactNode + secondaryText?: keyof T + secondaryRender?: (item: T) => React.ReactNode + align?: 'left' | 'center' | 'right' +} + +export type DataViewFooterCell = ReactNode | { primary: ReactNode; secondary?: ReactNode } + +export type DataViewColumn = + | (DataViewColumnBase & { key: keyof T - title: string | React.ReactNode render?: (item: T) => React.ReactNode - } - | { + }) + | (DataViewColumnBase & { key?: string - title: string | React.ReactNode render: (item: T) => React.ReactNode - } + }) // Type for footer object keys - allows data keys and custom string keys type FooterKeys = keyof T | string @@ -23,7 +31,7 @@ export type useDataViewProp = { itemMenu?: (item: T) => React.ReactNode onSelect?: (item: T, checked: boolean) => void emptyState?: () => React.ReactNode - footer?: () => Partial, React.ReactNode>> + footer?: () => Partial, DataViewFooterCell>> isFetching?: boolean } @@ -34,7 +42,7 @@ export type useDataViewPropReturn = { itemMenu?: (item: T) => React.ReactNode onSelect?: (item: T, checked: boolean) => void emptyState?: () => React.ReactNode - footer?: () => Partial, React.ReactNode>> + footer?: () => Partial, DataViewFooterCell>> isFetching?: boolean } diff --git a/src/components/Common/UI/Table/Table.module.scss b/src/components/Common/UI/Table/Table.module.scss index 6fda24404..c64089b19 100644 --- a/src/components/Common/UI/Table/Table.module.scss +++ b/src/components/Common/UI/Table/Table.module.scss @@ -37,13 +37,6 @@ } } - //Produces spacing between header and body - &:after { - content: ''; - display: table-row; - height: 2px; - } - & tr:last-child .react-aria-Column { border-bottom: 1px solid var(--g-colorBorder); cursor: default; @@ -98,7 +91,7 @@ :global(.react-aria-Cell), :global(.react-aria-Column) { - text-align: left; + text-align: inherit; outline: none; word-break: normal; @@ -106,9 +99,6 @@ outline: var(--g-focusRingWidth) solid var(--g-focusRingColor); outline-offset: -2px; } - &:last-child { - text-align: right; - } // Empty state row styling :global(.react-aria-Row[data-empty-state='true']) & { diff --git a/src/components/Common/UI/Tabs/Tabs.module.scss b/src/components/Common/UI/Tabs/Tabs.module.scss index 702c9ed43..40802a0c9 100644 --- a/src/components/Common/UI/Tabs/Tabs.module.scss +++ b/src/components/Common/UI/Tabs/Tabs.module.scss @@ -22,7 +22,6 @@ :global(.react-aria-TabList) { display: flex; gap: Helpers.toRem(4); - border-bottom: 1px solid var(--g-colorBorder); margin-bottom: Helpers.toRem(24); } @@ -33,7 +32,7 @@ margin: 0; cursor: pointer; font-size: var(--g-fontSizeSmall); - font-weight: var(--g-fontWeightSemibold); + font-weight: var(--g-fontWeightMedium); color: var(--g-colorBodySubContent); transition: all var(--g-transitionDuration); border-radius: Helpers.toRem(6); @@ -58,7 +57,7 @@ &[data-selected] { color: var(--g-colorBodyContent); - font-weight: var(--g-fontWeightSemibold); + font-weight: var(--g-fontWeightMedium); background: var(--g-colorBodyAccent); } diff --git a/src/components/Common/UI/Text/Text.module.scss b/src/components/Common/UI/Text/Text.module.scss index fb286edd5..5107c4f39 100644 --- a/src/components/Common/UI/Text/Text.module.scss +++ b/src/components/Common/UI/Text/Text.module.scss @@ -46,7 +46,7 @@ } .variant-leading { - font-weight: var(--g-fontWeightSemibold); + font-weight: var(--g-fontWeightMedium); } .variant-supporting { diff --git a/src/components/Payroll/PayrollConfiguration/PayrollConfigurationPresentation.module.scss b/src/components/Payroll/PayrollConfiguration/PayrollConfigurationPresentation.module.scss index 17838a9f6..8a99d3258 100644 --- a/src/components/Payroll/PayrollConfiguration/PayrollConfigurationPresentation.module.scss +++ b/src/components/Payroll/PayrollConfiguration/PayrollConfigurationPresentation.module.scss @@ -6,3 +6,7 @@ container-type: inline-size; width: 100%; } + +.excludedBadge { + margin-top: toRem(4); +} diff --git a/src/components/Payroll/PayrollConfiguration/PayrollConfigurationPresentation.tsx b/src/components/Payroll/PayrollConfiguration/PayrollConfigurationPresentation.tsx index 3faefe48b..0f025cc40 100644 --- a/src/components/Payroll/PayrollConfiguration/PayrollConfigurationPresentation.tsx +++ b/src/components/Payroll/PayrollConfiguration/PayrollConfigurationPresentation.tsx @@ -1,5 +1,5 @@ import type { ReactNode } from 'react' -import { useRef } from 'react' +import { useMemo, useRef } from 'react' import type { EmployeeCompensations } from '@gusto/embedded-api/models/components/payrollshow' import type { Employee } from '@gusto/embedded-api/models/components/employee' import type { PayrollPayPeriodType } from '@gusto/embedded-api/models/components/payrollpayperiodtype' @@ -88,13 +88,17 @@ export const PayrollConfigurationPresentation = ({ const breakpoints = useContainerBreakpoints({ ref: containerRef }) const isDesktop = breakpoints.includes('small') - const employeeMap = new Map(employeeDetails.map(employee => [employee.uuid, employee])) + const employeeMap = useMemo(() => { + return new Map(employeeDetails.map(employee => [employee.uuid, employee])) + }, [employeeDetails]) - const getEmployeeName = (employeeUuid: string) => { + const getEmployeeName = (employeeUuid?: string | null) => { + if (!employeeUuid) return '' const employee = employeeMap.get(employeeUuid) - return employee - ? firstLastName({ first_name: employee.firstName, last_name: employee.lastName }) - : null + if (!employee) { + return '' + } + return firstLastName({ first_name: employee.firstName, last_name: employee.lastName }) } return ( @@ -178,50 +182,61 @@ export const PayrollConfigurationPresentation = ({ label={t('employeeCompensationsTitle')} columns={[ { - title: {t('tableColumns.employees')}, + title: t('tableColumns.employees'), render: (item: EmployeeCompensations) => { - const employee = employeeMap.get(item.employeeUuid || '') + return getEmployeeName(item.employeeUuid) + }, + secondaryRender: (item: EmployeeCompensations) => { + const employee = item.employeeUuid + ? employeeMap.get(item.employeeUuid) + : undefined const payRateDisplay = formatEmployeePayRate(employee) + if (!payRateDisplay && !item.excluded) { + return undefined + } return ( - - {getEmployeeName(item.employeeUuid || '')} - {payRateDisplay && {payRateDisplay}} - {item.excluded && {t('skippedBadge')}} - + <> + {payRateDisplay} + {item.excluded && ( +
+ {t('skippedBadge')} +
+ )} + ) }, }, { - title: {t('tableColumns.hours')}, + title: t('tableColumns.hours'), render: (item: EmployeeCompensations) => { const hours = getRegularHours(item) const overtimeHours = getOvertimeHours(item) - return {formatHoursDisplay(hours + overtimeHours)} + return formatHoursDisplay(hours + overtimeHours) }, }, { - title: {t('tableColumns.timeOff')}, + title: t('tableColumns.timeOff'), render: (item: EmployeeCompensations) => { const ptoHours = getTotalPtoHours(item) - return {formatHoursDisplay(ptoHours)} + return formatHoursDisplay(ptoHours) }, }, { - title: {t('tableColumns.additionalEarnings')}, + title: t('tableColumns.additionalEarnings'), render: (item: EmployeeCompensations) => { const earnings = getAdditionalEarnings(item) - return {formatNumberAsCurrency(earnings)} + return formatNumberAsCurrency(earnings) }, }, { - title: {t('tableColumns.reimbursements')}, + title: t('tableColumns.reimbursements'), render: (item: EmployeeCompensations) => { const reimbursements = getReimbursements(item) - return {formatNumberAsCurrency(reimbursements)} + return formatNumberAsCurrency(reimbursements) }, }, { - title: {t('tableColumns.totalPay')}, + title: t('tableColumns.totalPay'), render: (item: PayrollEmployeeCompensationsType) => { const employee = employeeMap.get(item.employeeUuid || '') const calculatedGrossPay = employee @@ -233,7 +248,7 @@ export const PayrollConfigurationPresentation = ({ isOffCycle, ) : 0 - return {formatNumberAsCurrency(calculatedGrossPay)} + return formatNumberAsCurrency(calculatedGrossPay) }, }, ]} diff --git a/src/components/Payroll/PayrollList/PayrollListPresentation.tsx b/src/components/Payroll/PayrollList/PayrollListPresentation.tsx index 7f0bf2ece..4e9f05ef7 100644 --- a/src/components/Payroll/PayrollList/PayrollListPresentation.tsx +++ b/src/components/Payroll/PayrollList/PayrollListPresentation.tsx @@ -1,6 +1,6 @@ import type { Payroll } from '@gusto/embedded-api/models/components/payroll' import type { PayScheduleList } from '@gusto/embedded-api/models/components/payschedulelist' -import { useState, useRef } from 'react' +import { useMemo, useState, useRef } from 'react' import { useTranslation } from 'react-i18next' import type { ApiPayrollBlocker } from '../PayrollBlocker/payrollHelpers' import { PayrollBlockerAlerts } from '../PayrollBlocker/components/PayrollBlockerAlerts' @@ -94,6 +94,18 @@ export const PayrollListPresentation = ({ } } + const payScheduleNames = useMemo(() => { + return paySchedules.reduce>((acc, schedule) => { + acc[schedule.uuid] = schedule.name || schedule.customName || '' + return acc + }, {}) + }, [paySchedules]) + + const getPayScheduleDisplayName = (payScheduleUuid: string | null | undefined) => { + if (!payScheduleUuid) return '' + return payScheduleNames[payScheduleUuid] || '' + } + return (
@@ -133,37 +145,24 @@ export const PayrollListPresentation = ({ payPeriod?.endDate, ) - return ( - - - {startDate} - {endDate} - - - {paySchedules.find(schedule => schedule.uuid === payPeriod?.payScheduleUuid) - ?.name || - paySchedules.find(schedule => schedule.uuid === payPeriod?.payScheduleUuid) - ?.customName} - - - ) + return `${startDate} - ${endDate}` }, title: t('tableHeaders.0'), + secondaryRender: ({ payPeriod }) => + getPayScheduleDisplayName(payPeriod?.payScheduleUuid), }, { - render: ({ payrollType }) => {t(`type.${payrollType}`)}, + render: ({ payrollType }) => t(`type.${payrollType}`), title: t('tableHeaders.1'), }, { - render: ({ checkDate }) => ( - {dateFormatter.formatShortWithWeekdayAndYear(checkDate)} - ), + render: ({ checkDate }) => dateFormatter.formatShortWithWeekdayAndYear(checkDate), title: t('tableHeaders.2'), }, { title: t('tableHeaders.3'), - render: ({ payrollDeadline }) => ( - {dateFormatter.formatShortWithWeekdayAndYear(payrollDeadline)} - ), + render: ({ payrollDeadline }) => + dateFormatter.formatShortWithWeekdayAndYear(payrollDeadline), }, { title: t('tableHeaders.4'), @@ -173,6 +172,7 @@ export const PayrollListPresentation = ({ }, { title: '', + align: 'right', render: ({ payrollUuid, calculatedAt, processed, payPeriod }) => { if (processed) { return null diff --git a/src/components/Payroll/PayrollOverview/PayrollOverviewPresentation.tsx b/src/components/Payroll/PayrollOverview/PayrollOverviewPresentation.tsx index 376ceff82..9736bcc43 100644 --- a/src/components/Payroll/PayrollOverview/PayrollOverviewPresentation.tsx +++ b/src/components/Payroll/PayrollOverview/PayrollOverviewPresentation.tsx @@ -5,7 +5,7 @@ import type { } from '@gusto/embedded-api/models/components/payrollshow' import type { PayrollPayPeriodType } from '@gusto/embedded-api/models/components/payrollpayperiodtype' import type { CompanyBankAccount } from '@gusto/embedded-api/models/components/companybankaccount' -import { useState, useRef } from 'react' +import { useMemo, useState, useRef } from 'react' import type { Employee } from '@gusto/embedded-api/models/components/employee' import type { PayrollSubmissionBlockersType } from '@gusto/embedded-api/models/components/payrollsubmissionblockerstype' import type { PayrollFlowAlert } from '../PayrollFlow/PayrollFlowComponents' @@ -13,6 +13,7 @@ import { calculateTotalPayroll } from '../helpers' import { FastAchThresholdExceeded, GenericBlocker } from './SubmissionBlockers' import styles from './PayrollOverviewPresentation.module.scss' import { DataView, Flex, FlexItem } from '@/components/Common' +import type { DataViewColumn } from '@/components/Common/DataView/useDataView' import { useContainerBreakpoints } from '@/hooks/useContainerBreakpoints/useContainerBreakpoints' import { useComponentContext } from '@/contexts/ComponentAdapter/useComponentContext' import { useI18n } from '@/i18n' @@ -128,7 +129,23 @@ export const PayrollOverviewPresentation = ({ ) } - const employeeMap = new Map(employeeDetails.map(employee => [employee.uuid, employee])) + const employeeMap = useMemo(() => { + return new Map(employeeDetails.map(employee => [employee.uuid, employee])) + }, [employeeDetails]) + + const getEmployeeName = (employeeUuid?: string | null) => { + if (!employeeUuid) { + return '' + } + const employee = employeeMap.get(employeeUuid) + if (!employee) { + return '' + } + return firstLastName({ + first_name: employee.firstName, + last_name: employee.lastName, + }) + } const getEmployeeHours = ( employeeCompensations: EmployeeCompensations, @@ -162,53 +179,47 @@ export const PayrollOverviewPresentation = ({ !comp.excluded && comp.paymentMethod === PAYMENT_METHODS.check ? acc + 1 : acc, 0, ) ?? 0 - const companyPaysColumns = [ + const companyPaysColumns: DataViewColumn[] = [ { key: 'employeeName', title: t('tableHeaders.employees'), - render: (employeeCompensations: EmployeeCompensations) => ( - - {firstLastName({ - first_name: employeeMap.get(employeeCompensations.employeeUuid!)?.firstName, - last_name: employeeMap.get(employeeCompensations.employeeUuid!)?.lastName, - })} - - ), + render: (employeeCompensations: EmployeeCompensations) => + getEmployeeName(employeeCompensations.employeeUuid), }, { key: 'grossPay', title: t('tableHeaders.grossPay'), - render: (employeeCompensations: EmployeeCompensations) => ( - {formatCurrency(employeeCompensations.grossPay!)} - ), + align: 'right', + render: (employeeCompensations: EmployeeCompensations) => + formatCurrency(employeeCompensations.grossPay ?? 0), }, { key: 'reimbursements', title: t('tableHeaders.reimbursements'), - render: (employeeCompensation: EmployeeCompensations) => ( - {formatCurrency(getReimbursements(employeeCompensation))} - ), + align: 'right', + render: (employeeCompensation: EmployeeCompensations) => + formatCurrency(getReimbursements(employeeCompensation)), }, { key: 'companyTaxes', title: t('tableHeaders.companyTaxes'), - render: (employeeCompensation: EmployeeCompensations) => ( - {formatCurrency(getCompanyTaxes(employeeCompensation))} - ), + align: 'right', + render: (employeeCompensation: EmployeeCompensations) => + formatCurrency(getCompanyTaxes(employeeCompensation)), }, { key: 'companyBenefits', title: t('tableHeaders.companyBenefits'), - render: (employeeCompensation: EmployeeCompensations) => ( - {formatCurrency(getCompanyBenefits(employeeCompensation))} - ), + align: 'right', + render: (employeeCompensation: EmployeeCompensations) => + formatCurrency(getCompanyBenefits(employeeCompensation)), }, { key: 'companyPays', + align: 'right', title: t('tableHeaders.companyPays'), - render: (employeeCompensation: EmployeeCompensations) => ( - {formatCurrency(getCompanyCost(employeeCompensation))} - ), + render: (employeeCompensation: EmployeeCompensations) => + formatCurrency(getCompanyCost(employeeCompensation)), }, ] if (isProcessed) { @@ -240,23 +251,15 @@ export const PayrollOverviewPresentation = ({ columns={companyPaysColumns} data={payrollData.employeeCompensations!} footer={() => ({ - employeeName: ( - <> - {t('tableHeaders.footerTotalsLabel')} - {t('tableHeaders.footerTotalsDescription')} - - ), - grossPay: {formatCurrency(Number(payrollData.totals?.grossPay ?? 0))}, - reimbursements: ( - {formatCurrency(Number(payrollData.totals?.reimbursements ?? 0))} - ), - companyTaxes: ( - {formatCurrency(Number(payrollData.totals?.employerTaxes ?? 0))} - ), - companyBenefits: ( - {formatCurrency(Number(payrollData.totals?.benefits ?? 0))} - ), - companyPays: {formatCurrency(totalPayroll)}, + employeeName: { + primary: t('tableHeaders.footerTotalsLabel'), + secondary: t('tableHeaders.footerTotalsDescription'), + }, + grossPay: formatCurrency(Number(payrollData.totals?.grossPay ?? 0)), + reimbursements: formatCurrency(Number(payrollData.totals?.reimbursements ?? 0)), + companyTaxes: formatCurrency(Number(payrollData.totals?.employerTaxes ?? 0)), + companyBenefits: formatCurrency(Number(payrollData.totals?.benefits ?? 0)), + companyPays: formatCurrency(totalPayroll), })} /> ), @@ -270,86 +273,73 @@ export const PayrollOverviewPresentation = ({ columns={[ { title: t('tableHeaders.employees'), - render: (employeeCompensations: EmployeeCompensations) => ( - - {firstLastName({ - first_name: employeeMap.get(employeeCompensations.employeeUuid!)?.firstName, - last_name: employeeMap.get(employeeCompensations.employeeUuid!)?.lastName, - })} - - ), + render: (employeeCompensations: EmployeeCompensations) => + getEmployeeName(employeeCompensations.employeeUuid), }, { title: t('tableHeaders.compensationType'), - render: (employeeCompensations: EmployeeCompensations) => ( - - {employeeMap - .get(employeeCompensations.employeeUuid!) - ?.jobs?.reduce((acc, job) => { - if (job.primary) { - const flsaStatus = job.compensations?.find( - comp => comp.uuid === job.currentCompensationUuid, - )?.flsaStatus + render: (employeeCompensations: EmployeeCompensations) => { + const employee = employeeCompensations.employeeUuid + ? employeeMap.get(employeeCompensations.employeeUuid) + : undefined - switch (flsaStatus) { - case FlsaStatus.EXEMPT: - return t('compensationTypeLabels.exempt') - case FlsaStatus.NONEXEMPT: - return t('compensationTypeLabels.nonexempt') - default: - return flsaStatus ?? '' - } + return ( + employee?.jobs?.reduce((acc, job) => { + if (job.primary) { + const flsaStatus = job.compensations?.find( + comp => comp.uuid === job.currentCompensationUuid, + )?.flsaStatus + + switch (flsaStatus) { + case FlsaStatus.EXEMPT: + return t('compensationTypeLabels.exempt') + case FlsaStatus.NONEXEMPT: + return t('compensationTypeLabels.nonexempt') + default: + return flsaStatus ?? '' } - return acc - }, '')} - - ), + } + return acc + }, '') ?? '' + ) + }, }, { title: t('tableHeaders.regular'), - render: (employeeCompensations: EmployeeCompensations) => ( - - {getEmployeeHours(employeeCompensations)[ - compensationTypeLabels.REGULAR_HOURS_NAME - ] || 0} - - ), + align: 'right', + render: (employeeCompensations: EmployeeCompensations) => + getEmployeeHours(employeeCompensations)[ + compensationTypeLabels.REGULAR_HOURS_NAME + ] || 0, }, { title: t('tableHeaders.overtime'), - render: (employeeCompensations: EmployeeCompensations) => ( - - {getEmployeeHours(employeeCompensations)[compensationTypeLabels.OVERTIME_NAME] || - 0} - - ), + align: 'right', + render: (employeeCompensations: EmployeeCompensations) => + getEmployeeHours(employeeCompensations)[compensationTypeLabels.OVERTIME_NAME] || 0, }, { title: t('tableHeaders.doubleOT'), - render: (employeeCompensations: EmployeeCompensations) => ( - - {getEmployeeHours(employeeCompensations)[ - compensationTypeLabels.DOUBLE_OVERTIME_NAME - ] || 0} - - ), + align: 'right', + render: (employeeCompensations: EmployeeCompensations) => + getEmployeeHours(employeeCompensations)[ + compensationTypeLabels.DOUBLE_OVERTIME_NAME + ] || 0, }, { title: t('tableHeaders.timeOff'), - render: (employeeCompensations: EmployeeCompensations) => ( - {getEmployeePtoHours(employeeCompensations)} - ), + align: 'right', + render: (employeeCompensations: EmployeeCompensations) => + getEmployeePtoHours(employeeCompensations), }, { title: t('tableHeaders.totalHours'), - render: (employeeCompensations: EmployeeCompensations) => ( - - {Object.values(getEmployeeHours(employeeCompensations)).reduce( - (acc, hours) => acc + hours, - 0, - ) + getEmployeePtoHours(employeeCompensations)} - - ), + align: 'right', + render: (employeeCompensations: EmployeeCompensations) => + Object.values(getEmployeeHours(employeeCompensations)).reduce( + (acc, hours) => acc + hours, + 0, + ) + getEmployeePtoHours(employeeCompensations), }, ]} data={payrollData.employeeCompensations!} @@ -365,77 +355,64 @@ export const PayrollOverviewPresentation = ({ columns={[ { title: t('tableHeaders.employees'), - render: (employeeCompensations: EmployeeCompensations) => ( - - {firstLastName({ - first_name: employeeMap.get(employeeCompensations.employeeUuid!)?.firstName, - last_name: employeeMap.get(employeeCompensations.employeeUuid!)?.lastName, - })} - - ), + render: (employeeCompensations: EmployeeCompensations) => + getEmployeeName(employeeCompensations.employeeUuid), }, { title: t('tableHeaders.paymentType'), - render: (employeeCompensations: EmployeeCompensations) => ( - {employeeCompensations.paymentMethod ?? ''} - ), + render: (employeeCompensations: EmployeeCompensations) => + employeeCompensations.paymentMethod, }, { title: t('tableHeaders.grossPay'), - render: (employeeCompensations: EmployeeCompensations) => ( - {formatCurrency(employeeCompensations.grossPay ?? 0)} - ), + align: 'right', + render: (employeeCompensations: EmployeeCompensations) => + formatCurrency(employeeCompensations.grossPay ?? 0), }, { title: t('tableHeaders.deductions'), - render: (employeeCompensations: EmployeeCompensations) => ( - - {formatCurrency( - employeeCompensations.deductions?.reduce( - (acc, deduction) => acc + deduction.amount!, - 0, - ) ?? 0, - )} - - ), + align: 'right', + render: (employeeCompensations: EmployeeCompensations) => + formatCurrency( + employeeCompensations.deductions?.reduce( + (acc, deduction) => acc + (deduction.amount ?? 0), + 0, + ) ?? 0, + ), }, { title: t('tableHeaders.reimbursements'), - render: (employeeCompensations: EmployeeCompensations) => ( - {formatCurrency(getReimbursements(employeeCompensations))} - ), + align: 'right', + render: (employeeCompensations: EmployeeCompensations) => + formatCurrency(getReimbursements(employeeCompensations)), }, { title: t('tableHeaders.employeeTaxes'), - render: (employeeCompensations: EmployeeCompensations) => ( - - {formatCurrency( - employeeCompensations.taxes?.reduce( - (acc, tax) => (tax.employer ? acc : acc + tax.amount), - 0, - ) ?? 0, - )} - - ), + align: 'right', + render: (employeeCompensations: EmployeeCompensations) => + formatCurrency( + employeeCompensations.taxes?.reduce( + (acc, tax) => (tax.employer ? acc : acc + tax.amount), + 0, + ) ?? 0, + ), }, { title: t('tableHeaders.employeeBenefits'), - render: (employeeCompensations: EmployeeCompensations) => ( - - {formatCurrency( - employeeCompensations.benefits?.reduce( - (acc, benefit) => acc + (benefit.employeeDeduction ?? 0), - 0, - ) ?? 0, - )} - - ), + align: 'right', + render: (employeeCompensations: EmployeeCompensations) => + formatCurrency( + employeeCompensations.benefits?.reduce( + (acc, benefit) => acc + (benefit.employeeDeduction ?? 0), + 0, + ) ?? 0, + ), }, { title: t('tableHeaders.payment'), - render: (employeeCompensations: EmployeeCompensations) => ( - {formatCurrency(employeeCompensations.netPay ?? 0)} - ), + align: 'right', + render: (employeeCompensations: EmployeeCompensations) => + formatCurrency(employeeCompensations.netPay ?? 0), }, ]} data={payrollData.employeeCompensations!} @@ -453,27 +430,25 @@ export const PayrollOverviewPresentation = ({ { key: 'taxDescription', title: t('tableHeaders.taxDescription'), - render: taxKey => {taxKey}, + render: taxKey => taxKey, }, { key: 'byYourEmployees', title: t('tableHeaders.byYourEmployees'), - render: taxKey => {formatCurrency(taxes[taxKey]?.employee ?? 0)}, + align: 'right', + render: taxKey => formatCurrency(taxes[taxKey]?.employee ?? 0), }, { key: 'byYourCompany', title: t('tableHeaders.byYourCompany'), - render: taxKey => {formatCurrency(taxes[taxKey]?.employer ?? 0)}, + align: 'right', + render: taxKey => formatCurrency(taxes[taxKey]?.employer ?? 0), }, ]} footer={() => ({ - taxDescription: {t('totalsLabel')}, - byYourEmployees: ( - {formatCurrency(Number(payrollData.totals?.employeeTaxes ?? 0))} - ), - byYourCompany: ( - {formatCurrency(Number(payrollData.totals?.employerTaxes ?? 0))} - ), + taxDescription: t('totalsLabel'), + byYourEmployees: formatCurrency(Number(payrollData.totals?.employeeTaxes ?? 0)), + byYourCompany: formatCurrency(Number(payrollData.totals?.employerTaxes ?? 0)), })} data={Object.keys(taxes)} /> @@ -483,11 +458,12 @@ export const PayrollOverviewPresentation = ({ columns={[ { title: t('tableHeaders.debitedByGusto'), - render: ({ label }) => {label}, + render: ({ label }) => label, }, { title: t('tableHeaders.taxesTotal'), - render: ({ value }) => {formatCurrency(Number(value))}, + align: 'right', + render: ({ value }) => formatCurrency(Number(value)), }, ]} data={[ @@ -626,27 +602,23 @@ export const PayrollOverviewPresentation = ({ columns={[ { title: t('tableHeaders.totalPayroll'), - render: () => {formatCurrency(totalPayroll)}, + render: () => formatCurrency(totalPayroll), }, { title: t('tableHeaders.debitAmount'), - render: () => ( - {formatCurrency(Number(payrollData.totals?.companyDebit ?? 0))} - ), + render: () => formatCurrency(Number(payrollData.totals?.companyDebit ?? 0)), }, { title: t('tableHeaders.debitAccount'), - render: () => {bankAccount?.hiddenAccountNumber ?? ''}, + render: () => bankAccount?.hiddenAccountNumber ?? '', }, { title: t('tableHeaders.debitDate'), - render: () => {dateFormatter.formatShortWithYear(expectedDebitDate)}, + render: () => dateFormatter.formatShortWithYear(expectedDebitDate), }, { title: t('tableHeaders.employeesPayDate'), - render: () => ( - {dateFormatter.formatShortWithYear(payrollData.checkDate)} - ), + render: () => dateFormatter.formatShortWithYear(payrollData.checkDate), }, ]} data={[{}]}