From 401cac5965439ab6ef643b301ba1242b7135e2b3 Mon Sep 17 00:00:00 2001 From: Aaron Lee Date: Fri, 21 Nov 2025 13:43:49 -0500 Subject: [PATCH 1/7] refactor: dataview supporting secondary cell content. removal of additional text components --- .../DataView/DataCards/DataCards.module.scss | 12 + .../DataView/DataCards/DataCards.test.tsx | 14 +- .../Common/DataView/DataCards/DataCards.tsx | 24 +- .../DataView/DataTable/DataTable.module.scss | 10 + .../DataView/DataTable/DataTable.test.tsx | 17 +- .../Common/DataView/DataTable/DataTable.tsx | 29 +- .../Common/DataView/DataView.stories.tsx | 31 ++ .../Common/DataView/getColumnContent.ts | 36 +++ src/components/Common/DataView/useDataView.ts | 18 +- .../Common/UI/Tabs/Tabs.module.scss | 5 +- ...yrollConfigurationPresentation.module.scss | 4 + .../PayrollConfigurationPresentation.tsx | 61 ++-- .../PayrollList/PayrollListPresentation.tsx | 41 ++- .../PayrollOverviewPresentation.tsx | 296 ++++++++---------- 14 files changed, 343 insertions(+), 255 deletions(-) create mode 100644 src/components/Common/DataView/DataTable/DataTable.module.scss create mode 100644 src/components/Common/DataView/getColumnContent.ts diff --git a/src/components/Common/DataView/DataCards/DataCards.module.scss b/src/components/Common/DataView/DataCards/DataCards.module.scss index 673fb1493..47768dae1 100644 --- a/src/components/Common/DataView/DataCards/DataCards.module.scss +++ b/src/components/Common/DataView/DataCards/DataCards.module.scss @@ -7,9 +7,21 @@ 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%; } 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..63e34b8cb 100644 --- a/src/components/Common/DataView/DataCards/DataCards.tsx +++ b/src/components/Common/DataView/DataCards/DataCards.tsx @@ -1,3 +1,4 @@ +import { getColumnContent } from '../getColumnContent' import styles from './DataCards.module.scss' import type { useDataViewPropReturn } from '@/components/Common/DataView/useDataView' import { Flex } from '@/components/Common/Flex/Flex' @@ -40,15 +41,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}
+ )} +
+
+ ) + })} ))} 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..41d9f55b7 --- /dev/null +++ b/src/components/Common/DataView/DataTable/DataTable.module.scss @@ -0,0 +1,10 @@ +.cellContent { + display: flex; + flex-direction: column; + gap: 0.125rem; +} + +.cellSecondary { + 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..f8d74e268 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,6 +26,7 @@ const testColumns: useDataViewPropReturn['columns'] = [ { key: 'name', title: 'Name', + secondaryText: 'department', render: (item: MockData) => item.name, }, { @@ -67,6 +69,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({ diff --git a/src/components/Common/DataView/DataTable/DataTable.tsx b/src/components/Common/DataView/DataTable/DataTable.tsx index 5493a50bb..f19e41684 100644 --- a/src/components/Common/DataView/DataTable/DataTable.tsx +++ b/src/components/Common/DataView/DataTable/DataTable.tsx @@ -2,6 +2,8 @@ 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 styles from './DataTable.module.scss' import { useComponentContext } from '@/contexts/ComponentAdapter/useComponentContext' export type DataTableProps = { @@ -15,22 +17,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, @@ -86,9 +72,18 @@ export const DataTable = ({ ] : []), ...columns.map((column, colIndex) => { + const { primary, secondary } = getColumnContent(item, column) return { key: typeof column.key === 'string' ? column.key : `cell-${colIndex}`, - content: getCellContent(item, column), + content: + secondary !== undefined ? ( +
+
{primary}
+
{secondary}
+
+ ) : ( + primary + ), } }), ...(itemMenu diff --git a/src/components/Common/DataView/DataView.stories.tsx b/src/components/Common/DataView/DataView.stories.tsx index 0512d88d2..a4f57f8ce 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, diff --git a/src/components/Common/DataView/getColumnContent.ts b/src/components/Common/DataView/getColumnContent.ts new file mode 100644 index 000000000..b7c07c0f8 --- /dev/null +++ b/src/components/Common/DataView/getColumnContent.ts @@ -0,0 +1,36 @@ +import type { ReactNode } from 'react' +import type { DataViewColumn } from './useDataView' + +type ColumnContent = { + 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/useDataView.ts b/src/components/Common/DataView/useDataView.ts index bb9edc95e..04d306cef 100644 --- a/src/components/Common/DataView/useDataView.ts +++ b/src/components/Common/DataView/useDataView.ts @@ -1,17 +1,21 @@ import { useMemo } 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 +} + +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 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/Payroll/PayrollConfiguration/PayrollConfigurationPresentation.module.scss b/src/components/Payroll/PayrollConfiguration/PayrollConfigurationPresentation.module.scss index 17838a9f6..22f897a33 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: 0.25rem; +} 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..f34a5397a 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'), diff --git a/src/components/Payroll/PayrollOverview/PayrollOverviewPresentation.tsx b/src/components/Payroll/PayrollOverview/PayrollOverviewPresentation.tsx index 376ceff82..bdf58fb31 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,42 @@ 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!)} - ), + render: (employeeCompensations: EmployeeCompensations) => + formatCurrency(employeeCompensations.grossPay ?? 0), }, { key: 'reimbursements', title: t('tableHeaders.reimbursements'), - render: (employeeCompensation: EmployeeCompensations) => ( - {formatCurrency(getReimbursements(employeeCompensation))} - ), + render: (employeeCompensation: EmployeeCompensations) => + formatCurrency(getReimbursements(employeeCompensation)), }, { key: 'companyTaxes', title: t('tableHeaders.companyTaxes'), - render: (employeeCompensation: EmployeeCompensations) => ( - {formatCurrency(getCompanyTaxes(employeeCompensation))} - ), + render: (employeeCompensation: EmployeeCompensations) => + formatCurrency(getCompanyTaxes(employeeCompensation)), }, { key: 'companyBenefits', title: t('tableHeaders.companyBenefits'), - render: (employeeCompensation: EmployeeCompensations) => ( - {formatCurrency(getCompanyBenefits(employeeCompensation))} - ), + render: (employeeCompensation: EmployeeCompensations) => + formatCurrency(getCompanyBenefits(employeeCompensation)), }, { key: 'companyPays', title: t('tableHeaders.companyPays'), - render: (employeeCompensation: EmployeeCompensations) => ( - {formatCurrency(getCompanyCost(employeeCompensation))} - ), + render: (employeeCompensation: EmployeeCompensations) => + formatCurrency(getCompanyCost(employeeCompensation)), }, ] if (isProcessed) { @@ -242,21 +248,16 @@ export const PayrollOverviewPresentation = ({ footer={() => ({ employeeName: ( <> - {t('tableHeaders.footerTotalsLabel')} - {t('tableHeaders.footerTotalsDescription')} + {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)}, + 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 +271,68 @@ 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 + + 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 ?? '' - } + 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} - - ), + render: (employeeCompensations: EmployeeCompensations) => + getEmployeeHours(employeeCompensations)[ + compensationTypeLabels.REGULAR_HOURS_NAME + ] || 0, }, { title: t('tableHeaders.overtime'), - render: (employeeCompensations: EmployeeCompensations) => ( - - {getEmployeeHours(employeeCompensations)[compensationTypeLabels.OVERTIME_NAME] || - 0} - - ), + render: (employeeCompensations: EmployeeCompensations) => + getEmployeeHours(employeeCompensations)[compensationTypeLabels.OVERTIME_NAME] || 0, }, { title: t('tableHeaders.doubleOT'), - render: (employeeCompensations: EmployeeCompensations) => ( - - {getEmployeeHours(employeeCompensations)[ - compensationTypeLabels.DOUBLE_OVERTIME_NAME - ] || 0} - - ), + render: (employeeCompensations: EmployeeCompensations) => + getEmployeeHours(employeeCompensations)[ + compensationTypeLabels.DOUBLE_OVERTIME_NAME + ] || 0, }, { title: t('tableHeaders.timeOff'), - render: (employeeCompensations: EmployeeCompensations) => ( - {getEmployeePtoHours(employeeCompensations)} - ), + render: (employeeCompensations: EmployeeCompensations) => + getEmployeePtoHours(employeeCompensations), }, { title: t('tableHeaders.totalHours'), - render: (employeeCompensations: EmployeeCompensations) => ( - - {Object.values(getEmployeeHours(employeeCompensations)).reduce( - (acc, hours) => acc + hours, - 0, - ) + getEmployeePtoHours(employeeCompensations)} - - ), + render: (employeeCompensations: EmployeeCompensations) => + Object.values(getEmployeeHours(employeeCompensations)).reduce( + (acc, hours) => acc + hours, + 0, + ) + getEmployeePtoHours(employeeCompensations), }, ]} data={payrollData.employeeCompensations!} @@ -365,77 +348,58 @@ 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)} - ), + 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, - )} - - ), + 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))} - ), + 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, - )} - - ), + 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, - )} - - ), + 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)} - ), + render: (employeeCompensations: EmployeeCompensations) => + formatCurrency(employeeCompensations.netPay ?? 0), }, ]} data={payrollData.employeeCompensations!} @@ -453,27 +417,23 @@ 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)}, + render: taxKey => formatCurrency(taxes[taxKey]?.employee ?? 0), }, { key: 'byYourCompany', title: t('tableHeaders.byYourCompany'), - render: taxKey => {formatCurrency(taxes[taxKey]?.employer ?? 0)}, + 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 +443,11 @@ export const PayrollOverviewPresentation = ({ columns={[ { title: t('tableHeaders.debitedByGusto'), - render: ({ label }) => {label}, + render: ({ label }) => label, }, { title: t('tableHeaders.taxesTotal'), - render: ({ value }) => {formatCurrency(Number(value))}, + render: ({ value }) => formatCurrency(Number(value)), }, ]} data={[ @@ -626,27 +586,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={[{}]} From b2db6f81cf06c364878cfd7fb1797390edb620a7 Mon Sep 17 00:00:00 2001 From: Aaron Lee Date: Fri, 21 Nov 2025 16:13:39 -0500 Subject: [PATCH 2/7] refactor: add support for secondary footer text, add ability to set column alignment --- .../DataView/DataCards/DataCards.module.scss | 8 +++ .../Common/DataView/DataCards/DataCards.tsx | 17 ++++-- .../DataView/DataTable/DataTable.module.scss | 46 +++++++++++++++ .../DataView/DataTable/DataTable.test.tsx | 11 ++++ .../Common/DataView/DataTable/DataTable.tsx | 56 ++++++++++++++----- .../Common/DataView/getFooterContent.ts | 25 +++++++++ src/components/Common/DataView/useDataView.ts | 8 ++- .../Common/UI/Table/Table.module.scss | 5 +- .../PayrollList/PayrollListPresentation.tsx | 1 + .../PayrollOverviewPresentation.tsx | 30 +++++++--- 10 files changed, 174 insertions(+), 33 deletions(-) create mode 100644 src/components/Common/DataView/getFooterContent.ts diff --git a/src/components/Common/DataView/DataCards/DataCards.module.scss b/src/components/Common/DataView/DataCards/DataCards.module.scss index 47768dae1..ae39e6ca7 100644 --- a/src/components/Common/DataView/DataCards/DataCards.module.scss +++ b/src/components/Common/DataView/DataCards/DataCards.module.scss @@ -24,4 +24,12 @@ h5.columnTitle { .footerItem { width: 100%; + display: flex; + flex-direction: column; + gap: 0.125rem; +} + +.footerSecondary { + color: var(--g-colorBodySubContent); + font-size: var(--g-fontSizeSmall); } diff --git a/src/components/Common/DataView/DataCards/DataCards.tsx b/src/components/Common/DataView/DataCards/DataCards.tsx index 63e34b8cb..e7795ee94 100644 --- a/src/components/Common/DataView/DataCards/DataCards.tsx +++ b/src/components/Common/DataView/DataCards/DataCards.tsx @@ -1,4 +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' @@ -65,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 index 41d9f55b7..5b0f0aa13 100644 --- a/src/components/Common/DataView/DataTable/DataTable.module.scss +++ b/src/components/Common/DataView/DataTable/DataTable.module.scss @@ -1,3 +1,16 @@ +.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; @@ -8,3 +21,36 @@ 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: 0.125rem; +} + +.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 f8d74e268..83722a662 100644 --- a/src/components/Common/DataView/DataTable/DataTable.test.tsx +++ b/src/components/Common/DataView/DataTable/DataTable.test.tsx @@ -32,6 +32,7 @@ const testColumns: useDataViewPropReturn['columns'] = [ { key: 'age', title: 'Age', + align: 'right', render: (item: MockData) => item.age.toString(), }, ] @@ -132,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 f19e41684..7b86db509 100644 --- a/src/components/Common/DataView/DataTable/DataTable.tsx +++ b/src/components/Common/DataView/DataTable/DataTable.tsx @@ -3,6 +3,7 @@ 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' @@ -39,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 ? [ { @@ -73,17 +81,24 @@ export const DataTable = ({ : []), ...columns.map((column, colIndex) => { const { primary, secondary } = getColumnContent(item, column) + const alignment = column.align ?? 'left' + const cellContent = + secondary !== undefined ? ( +
+
{primary}
+
{secondary}
+
+ ) : ( + primary + ) + return { key: typeof column.key === 'string' ? column.key : `cell-${colIndex}`, - content: - secondary !== undefined ? ( -
-
{primary}
-
{secondary}
-
- ) : ( - primary - ), + content: ( +
+ {cellContent} +
+ ), } }), ...(itemMenu @@ -119,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}
+ )} +
+ ), }) }) @@ -129,7 +155,7 @@ export const DataTable = ({ if (itemMenu) { footerCells.push({ key: 'footer-actions', - content: '', + content:
, }) } 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 04d306cef..e237ebd08 100644 --- a/src/components/Common/DataView/useDataView.ts +++ b/src/components/Common/DataView/useDataView.ts @@ -1,12 +1,16 @@ import { useMemo } from 'react' +import type { ReactNode } from 'react' import type { PaginationControlProps } from '@/components/Common/PaginationControl/PaginationControlTypes' 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 @@ -27,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 } @@ -38,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..eb65f055f 100644 --- a/src/components/Common/UI/Table/Table.module.scss +++ b/src/components/Common/UI/Table/Table.module.scss @@ -98,7 +98,7 @@ :global(.react-aria-Cell), :global(.react-aria-Column) { - text-align: left; + text-align: inherit; outline: none; word-break: normal; @@ -106,9 +106,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/Payroll/PayrollList/PayrollListPresentation.tsx b/src/components/Payroll/PayrollList/PayrollListPresentation.tsx index f34a5397a..4e9f05ef7 100644 --- a/src/components/Payroll/PayrollList/PayrollListPresentation.tsx +++ b/src/components/Payroll/PayrollList/PayrollListPresentation.tsx @@ -172,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 bdf58fb31..9736bcc43 100644 --- a/src/components/Payroll/PayrollOverview/PayrollOverviewPresentation.tsx +++ b/src/components/Payroll/PayrollOverview/PayrollOverviewPresentation.tsx @@ -189,29 +189,34 @@ export const PayrollOverviewPresentation = ({ { key: 'grossPay', title: t('tableHeaders.grossPay'), + align: 'right', render: (employeeCompensations: EmployeeCompensations) => formatCurrency(employeeCompensations.grossPay ?? 0), }, { key: 'reimbursements', title: t('tableHeaders.reimbursements'), + align: 'right', render: (employeeCompensation: EmployeeCompensations) => formatCurrency(getReimbursements(employeeCompensation)), }, { key: 'companyTaxes', title: t('tableHeaders.companyTaxes'), + align: 'right', render: (employeeCompensation: EmployeeCompensations) => formatCurrency(getCompanyTaxes(employeeCompensation)), }, { key: 'companyBenefits', title: t('tableHeaders.companyBenefits'), + align: 'right', render: (employeeCompensation: EmployeeCompensations) => formatCurrency(getCompanyBenefits(employeeCompensation)), }, { key: 'companyPays', + align: 'right', title: t('tableHeaders.companyPays'), render: (employeeCompensation: EmployeeCompensations) => formatCurrency(getCompanyCost(employeeCompensation)), @@ -246,13 +251,10 @@ export const PayrollOverviewPresentation = ({ columns={companyPaysColumns} data={payrollData.employeeCompensations!} footer={() => ({ - employeeName: ( - <> - {t('tableHeaders.footerTotalsLabel')} -
- {t('tableHeaders.footerTotalsDescription')} - - ), + 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)), @@ -304,6 +306,7 @@ export const PayrollOverviewPresentation = ({ }, { title: t('tableHeaders.regular'), + align: 'right', render: (employeeCompensations: EmployeeCompensations) => getEmployeeHours(employeeCompensations)[ compensationTypeLabels.REGULAR_HOURS_NAME @@ -311,11 +314,13 @@ export const PayrollOverviewPresentation = ({ }, { title: t('tableHeaders.overtime'), + align: 'right', render: (employeeCompensations: EmployeeCompensations) => getEmployeeHours(employeeCompensations)[compensationTypeLabels.OVERTIME_NAME] || 0, }, { title: t('tableHeaders.doubleOT'), + align: 'right', render: (employeeCompensations: EmployeeCompensations) => getEmployeeHours(employeeCompensations)[ compensationTypeLabels.DOUBLE_OVERTIME_NAME @@ -323,11 +328,13 @@ export const PayrollOverviewPresentation = ({ }, { title: t('tableHeaders.timeOff'), + align: 'right', render: (employeeCompensations: EmployeeCompensations) => getEmployeePtoHours(employeeCompensations), }, { title: t('tableHeaders.totalHours'), + align: 'right', render: (employeeCompensations: EmployeeCompensations) => Object.values(getEmployeeHours(employeeCompensations)).reduce( (acc, hours) => acc + hours, @@ -358,11 +365,13 @@ export const PayrollOverviewPresentation = ({ }, { title: t('tableHeaders.grossPay'), + align: 'right', render: (employeeCompensations: EmployeeCompensations) => formatCurrency(employeeCompensations.grossPay ?? 0), }, { title: t('tableHeaders.deductions'), + align: 'right', render: (employeeCompensations: EmployeeCompensations) => formatCurrency( employeeCompensations.deductions?.reduce( @@ -373,11 +382,13 @@ export const PayrollOverviewPresentation = ({ }, { title: t('tableHeaders.reimbursements'), + align: 'right', render: (employeeCompensations: EmployeeCompensations) => formatCurrency(getReimbursements(employeeCompensations)), }, { title: t('tableHeaders.employeeTaxes'), + align: 'right', render: (employeeCompensations: EmployeeCompensations) => formatCurrency( employeeCompensations.taxes?.reduce( @@ -388,6 +399,7 @@ export const PayrollOverviewPresentation = ({ }, { title: t('tableHeaders.employeeBenefits'), + align: 'right', render: (employeeCompensations: EmployeeCompensations) => formatCurrency( employeeCompensations.benefits?.reduce( @@ -398,6 +410,7 @@ export const PayrollOverviewPresentation = ({ }, { title: t('tableHeaders.payment'), + align: 'right', render: (employeeCompensations: EmployeeCompensations) => formatCurrency(employeeCompensations.netPay ?? 0), }, @@ -422,11 +435,13 @@ export const PayrollOverviewPresentation = ({ { key: 'byYourEmployees', title: t('tableHeaders.byYourEmployees'), + align: 'right', render: taxKey => formatCurrency(taxes[taxKey]?.employee ?? 0), }, { key: 'byYourCompany', title: t('tableHeaders.byYourCompany'), + align: 'right', render: taxKey => formatCurrency(taxes[taxKey]?.employer ?? 0), }, ]} @@ -447,6 +462,7 @@ export const PayrollOverviewPresentation = ({ }, { title: t('tableHeaders.taxesTotal'), + align: 'right', render: ({ value }) => formatCurrency(Number(value)), }, ]} From 7df5bb930629b142a0a09653d6300f8fd352567e Mon Sep 17 00:00:00 2001 From: Aaron Lee Date: Fri, 21 Nov 2025 17:54:01 -0500 Subject: [PATCH 3/7] refactor: dataview renders actions via props --- .../DataActions/DataViewActions.module.scss | 15 ++ .../DataView/DataActions/DataViewActions.tsx | 37 ++++ .../DataView/DataCards/DataCards.module.scss | 7 + .../Common/DataView/DataCards/DataCards.tsx | 77 +++++--- .../DataView/DataTable/DataTable.test.tsx | 27 +++ .../Common/DataView/DataTable/DataTable.tsx | 42 ++++- .../Common/DataView/DataView.stories.tsx | 39 +++-- src/components/Common/DataView/DataView.tsx | 10 +- src/components/Common/DataView/useDataView.ts | 31 +++- .../PayrollList/PayrollListPresentation.tsx | 164 +++++++++--------- 10 files changed, 315 insertions(+), 134 deletions(-) create mode 100644 src/components/Common/DataView/DataActions/DataViewActions.module.scss create mode 100644 src/components/Common/DataView/DataActions/DataViewActions.tsx diff --git a/src/components/Common/DataView/DataActions/DataViewActions.module.scss b/src/components/Common/DataView/DataActions/DataViewActions.module.scss new file mode 100644 index 000000000..931fc6447 --- /dev/null +++ b/src/components/Common/DataView/DataActions/DataViewActions.module.scss @@ -0,0 +1,15 @@ +.actionsRow { + display: inline-flex; + align-items: center; + justify-content: flex-end; + gap: 0.5rem; + width: 100%; +} + +.actionsColumn { + display: flex; + flex-direction: column; + align-items: stretch; + gap: 0.5rem; + width: 100%; +} diff --git a/src/components/Common/DataView/DataActions/DataViewActions.tsx b/src/components/Common/DataView/DataActions/DataViewActions.tsx new file mode 100644 index 000000000..bd42120ef --- /dev/null +++ b/src/components/Common/DataView/DataActions/DataViewActions.tsx @@ -0,0 +1,37 @@ +import type { DataViewAction } from '../useDataView' +import styles from './DataViewActions.module.scss' +import { HamburgerMenu } from '@/components/Common/HamburgerMenu' +import { useComponentContext } from '@/contexts/ComponentAdapter/useComponentContext' + +type DataViewActionsProps = { + actions: DataViewAction[] + orientation: 'row' | 'column' +} + +export const DataViewActions = ({ actions, orientation }: DataViewActionsProps) => { + const Components = useComponentContext() + + if (actions.length === 0) { + return null + } + + const containerClass = orientation === 'row' ? styles.actionsRow : styles.actionsColumn + + return ( +
+ {actions.map((action, index) => { + if (action.type === 'button') { + const { label, onClick, buttonProps } = action + return ( + + {label} + + ) + } + + const { items, ...menuProps } = action + return + })} +
+ ) +} diff --git a/src/components/Common/DataView/DataCards/DataCards.module.scss b/src/components/Common/DataView/DataCards/DataCards.module.scss index ae39e6ca7..9db7dbf44 100644 --- a/src/components/Common/DataView/DataCards/DataCards.module.scss +++ b/src/components/Common/DataView/DataCards/DataCards.module.scss @@ -33,3 +33,10 @@ h5.columnTitle { color: var(--g-colorBodySubContent); font-size: var(--g-fontSizeSmall); } + +.menuWrapper { + display: flex; + flex-direction: column; + gap: 0.5rem; + align-items: flex-end; +} diff --git a/src/components/Common/DataView/DataCards/DataCards.tsx b/src/components/Common/DataView/DataCards/DataCards.tsx index e7795ee94..541c4f0d7 100644 --- a/src/components/Common/DataView/DataCards/DataCards.tsx +++ b/src/components/Common/DataView/DataCards/DataCards.tsx @@ -1,5 +1,6 @@ import { getColumnContent } from '../getColumnContent' import { getFooterContent } from '../getFooterContent' +import { DataViewActions } from '../DataActions/DataViewActions' import styles from './DataCards.module.scss' import type { useDataViewPropReturn } from '@/components/Common/DataView/useDataView' import { Flex } from '@/components/Common/Flex/Flex' @@ -12,6 +13,7 @@ export type DataCardsProps = { onSelect?: useDataViewPropReturn['onSelect'] emptyState?: useDataViewPropReturn['emptyState'] footer?: useDataViewPropReturn['footer'] + rowActions?: useDataViewPropReturn['rowActions'] } export const DataCards = ({ @@ -21,6 +23,7 @@ export const DataCards = ({ onSelect, emptyState, footer, + rowActions, }: DataCardsProps) => { const Components = useComponentContext() return ( @@ -30,35 +33,57 @@ export const DataCards = ({ {emptyState()}
)} - {data.map((item, index) => ( -
- { - onSelect(item, checked) - } - : undefined - } - > - {columns.map((column, index) => { - const { primary, secondary } = getColumnContent(item, column) - return ( - - {column.title &&
{column.title}
} + {data.map((item, index) => { + const inlineMenu = itemMenu?.(item) + const actionButtons = rowActions?.buttons?.(item) ?? [] + const actionMenu = rowActions?.menuItems?.(item) ?? null + + const cardMenu = + inlineMenu || actionMenu ? ( +
+ {actionMenu && } + {inlineMenu} +
+ ) : undefined + + return ( +
+ { + onSelect(item, checked) + } + : undefined + } + > + {columns.map((column, index) => { + const { primary, secondary } = getColumnContent(item, column) + + return ( + + {column.title &&
{column.title}
} +
+
{primary}
+ {secondary !== undefined && ( +
{secondary}
+ )} +
+
+ ) + })} + {actionButtons.length > 0 && ( +
-
{primary}
- {secondary !== undefined && ( -
{secondary}
- )} +
- ) - })} -
-
- ))} + )} +
+
+ ) + })} {footer && (
diff --git a/src/components/Common/DataView/DataTable/DataTable.test.tsx b/src/components/Common/DataView/DataTable/DataTable.test.tsx index 83722a662..dbd158d04 100644 --- a/src/components/Common/DataView/DataTable/DataTable.test.tsx +++ b/src/components/Common/DataView/DataTable/DataTable.test.tsx @@ -133,6 +133,33 @@ describe('DataTable Component', () => { expect(screen.getByText('55')).toBeInTheDocument() }) + test('should render row actions with buttons', async () => { + const onAction = vi.fn() + renderTable({ + data: testData, + columns: testColumns, + label: 'Actions Table', + rowActions: { + header: 'Actions', + align: 'right', + buttons: (item: MockData) => [ + { + type: 'button', + label: `Select ${item.name}`, + onClick: () => onAction(item.name), + buttonProps: { + variant: 'secondary', + }, + }, + ], + }, + }) + + const actionButton = screen.getByText('Select Alice') + await userEvent.click(actionButton) + expect(onAction).toHaveBeenCalledWith('Alice') + }) + test('should apply alignment when column specifies it', () => { const { container } = renderTable({ data: testData, diff --git a/src/components/Common/DataView/DataTable/DataTable.tsx b/src/components/Common/DataView/DataTable/DataTable.tsx index 7b86db509..a7748ae76 100644 --- a/src/components/Common/DataView/DataTable/DataTable.tsx +++ b/src/components/Common/DataView/DataTable/DataTable.tsx @@ -3,6 +3,7 @@ import type { useDataViewPropReturn } from '../useDataView' import type { TableData, TableRow, TableProps } from '../../UI/Table/TableTypes' import { VisuallyHidden } from '../../VisuallyHidden' import { getColumnContent } from '../getColumnContent' +import { DataViewActions } from '../DataActions/DataViewActions' import { getFooterContent } from '../getFooterContent' import styles from './DataTable.module.scss' import { useComponentContext } from '@/contexts/ComponentAdapter/useComponentContext' @@ -15,6 +16,7 @@ export type DataTableProps = { onSelect?: useDataViewPropReturn['onSelect'] emptyState?: useDataViewPropReturn['emptyState'] footer?: useDataViewPropReturn['footer'] + rowActions?: useDataViewPropReturn['rowActions'] variant?: TableProps['variant'] } @@ -26,6 +28,7 @@ export const DataTable = ({ onSelect, emptyState, footer, + rowActions, variant, }: DataTableProps) => { const Components = useComponentContext() @@ -59,6 +62,18 @@ export const DataTable = ({ }, ] : []), + ...(rowActions + ? [ + { + key: 'row-actions-header', + content: ( +
+ {rowActions.header || ''} +
+ ), + }, + ] + : []), ] const rows: TableRow[] = data.map((item, rowIndex) => { @@ -80,8 +95,8 @@ export const DataTable = ({ ] : []), ...columns.map((column, colIndex) => { - const { primary, secondary } = getColumnContent(item, column) const alignment = column.align ?? 'left' + const { primary, secondary } = getColumnContent(item, column) const cellContent = secondary !== undefined ? (
@@ -109,6 +124,24 @@ export const DataTable = ({ }, ] : []), + ...(rowActions + ? [ + { + key: `row-actions-${rowIndex}`, + content: ( +
+ +
+ ), + }, + ] + : []), ] return { @@ -159,6 +192,13 @@ export const DataTable = ({ }) } + if (rowActions) { + footerCells.push({ + key: 'footer-row-actions', + content:
, + }) + } + return footerCells } diff --git a/src/components/Common/DataView/DataView.stories.tsx b/src/components/Common/DataView/DataView.stories.tsx index a4f57f8ce..397afad1e 100644 --- a/src/components/Common/DataView/DataView.stories.tsx +++ b/src/components/Common/DataView/DataView.stories.tsx @@ -1,7 +1,6 @@ import { action } from '@ladle/react' import { DataView } from '@/components/Common/DataView/DataView' import { useDataView } from '@/components/Common/DataView/useDataView' -import { HamburgerMenu } from '@/components/Common/HamburgerMenu' import TrashCanSvg from '@/assets/icons/trashcan.svg?react' import PencilSvg from '@/assets/icons/pencil.svg?react' @@ -196,15 +195,16 @@ export const DataViewWithMenu = () => { { key: 'amount', title: 'Amount' }, { key: 'payTimePeriod', title: 'Pay Time Period' }, ], - itemMenu: item => { - return ( - , onClick: () => {} }, - { label: 'Delete', icon: , onClick: () => {} }, - ]} - /> - ) + rowActions: { + header: '', + align: 'right', + menuItems: () => ({ + type: 'menu', + items: [ + { label: 'Edit', icon: , onClick: () => {} }, + { label: 'Delete', icon: , onClick: () => {} }, + ], + }), }, }) @@ -220,15 +220,16 @@ export const DataViewSelectableWithMenu = () => { { key: 'amount', title: 'Amount' }, { key: 'payTimePeriod', title: 'Pay Time Period' }, ], - itemMenu: item => { - return ( - , onClick: () => {} }, - { label: 'Delete', icon: , onClick: () => {} }, - ]} - /> - ) + rowActions: { + header: '', + align: 'right', + menuItems: () => ({ + type: 'menu', + items: [ + { label: 'Edit', icon: , onClick: () => {} }, + { label: 'Delete', icon: , onClick: () => {} }, + ], + }), }, onSelect: (item, checked) => { action('onSelect')({ diff --git a/src/components/Common/DataView/DataView.tsx b/src/components/Common/DataView/DataView.tsx index 532cbb0aa..6e835e72d 100644 --- a/src/components/Common/DataView/DataView.tsx +++ b/src/components/Common/DataView/DataView.tsx @@ -21,6 +21,7 @@ export type DataViewProps = { isFetching?: boolean variant?: TableProps['variant'] emptyState?: useDataViewPropReturn['emptyState'] + rowActions?: useDataViewPropReturn['rowActions'] } export const DataView = ({ @@ -31,6 +32,7 @@ export const DataView = ({ footer, variant, emptyState, + rowActions, ...dataViewProps }: DataViewProps) => { const containerRef = useRef(null) @@ -56,7 +58,13 @@ export const DataView = ({ }} > {isBreakpointsDetected && ( - + )} {pagination && }
diff --git a/src/components/Common/DataView/useDataView.ts b/src/components/Common/DataView/useDataView.ts index e237ebd08..1c40bb629 100644 --- a/src/components/Common/DataView/useDataView.ts +++ b/src/components/Common/DataView/useDataView.ts @@ -1,6 +1,8 @@ import { useMemo } from 'react' import type { ReactNode } from 'react' import type { PaginationControlProps } from '@/components/Common/PaginationControl/PaginationControlTypes' +import type { ButtonProps } from '@/components/Common/UI/Button/ButtonTypes' +import type { MenuItem } from '@/components/Common/UI/Menu/MenuTypes' type DataViewColumnBase = { title: string | React.ReactNode @@ -11,6 +13,24 @@ type DataViewColumnBase = { export type DataViewFooterCell = ReactNode | { primary: ReactNode; secondary?: ReactNode } +export type DataViewButtonAction = { + type: 'button' + label: ReactNode + onClick: () => void + buttonProps?: Partial +} + +export type DataViewMenuAction = { + type: 'menu' + items: MenuItem[] + triggerLabel?: string + menuLabel?: string + onClose?: () => void + isLoading?: boolean +} + +export type DataViewAction = DataViewButtonAction | DataViewMenuAction + export type DataViewColumn = | (DataViewColumnBase & { key: keyof T @@ -33,6 +53,12 @@ export type useDataViewProp = { emptyState?: () => React.ReactNode footer?: () => Partial, DataViewFooterCell>> isFetching?: boolean + rowActions?: { + header?: ReactNode + align?: 'left' | 'center' | 'right' + buttons?: (item: T) => DataViewButtonAction[] + menuItems?: (item: T) => DataViewMenuAction | null + } } export type useDataViewPropReturn = { @@ -44,6 +70,7 @@ export type useDataViewPropReturn = { emptyState?: () => React.ReactNode footer?: () => Partial, DataViewFooterCell>> isFetching?: boolean + rowActions?: NonNullable['rowActions']> } export const useDataView = ({ @@ -55,6 +82,7 @@ export const useDataView = ({ emptyState, footer, isFetching, + rowActions, }: useDataViewProp): useDataViewPropReturn => { const dataViewProps = useMemo(() => { return { @@ -66,8 +94,9 @@ export const useDataView = ({ emptyState, footer, isFetching, + rowActions, } - }, [pagination, data, columns, itemMenu, onSelect, emptyState, footer, isFetching]) + }, [pagination, data, columns, itemMenu, onSelect, emptyState, footer, isFetching, rowActions]) return dataViewProps } diff --git a/src/components/Payroll/PayrollList/PayrollListPresentation.tsx b/src/components/Payroll/PayrollList/PayrollListPresentation.tsx index 4e9f05ef7..729f7dfc5 100644 --- a/src/components/Payroll/PayrollList/PayrollListPresentation.tsx +++ b/src/components/Payroll/PayrollList/PayrollListPresentation.tsx @@ -6,13 +6,13 @@ import type { ApiPayrollBlocker } from '../PayrollBlocker/payrollHelpers' import { PayrollBlockerAlerts } from '../PayrollBlocker/components/PayrollBlockerAlerts' import type { PayrollType } from './types' import styles from './PayrollListPresentation.module.scss' -import { DataView, Flex, HamburgerMenu } from '@/components/Common' +import { DataView, Flex } from '@/components/Common' import { useComponentContext } from '@/contexts/ComponentAdapter/useComponentContext' import { useI18n } from '@/i18n' import { formatDateToStringDate } from '@/helpers/dateFormatting' import { useDateFormatter } from '@/hooks/useDateFormatter' import FeatureIconCheck from '@/assets/icons/feature-icon-check.svg?react' -import useContainerBreakpoints from '@/hooks/useContainerBreakpoints/useContainerBreakpoints' +import type { DataViewMenuAction } from '@/components/Common/DataView/useDataView' interface PresentationPayroll extends Payroll { payrollType: PayrollType @@ -43,13 +43,11 @@ export const PayrollListPresentation = ({ skippingPayrollId, blockers, }: PayrollListPresentationProps) => { - const { Badge, Button, Dialog, Heading, Text, Alert } = useComponentContext() + const { Badge, Dialog, Heading, Text, Alert } = useComponentContext() useI18n('Payroll.PayrollList') const { t } = useTranslation('Payroll.PayrollList') const dateFormatter = useDateFormatter() const containerRef = useRef(null) - const breakpoints = useContainerBreakpoints({ ref: containerRef }) - const isDesktop = breakpoints.includes('small') const [skipPayrollDialogState, setSkipPayrollDialogState] = useState<{ isOpen: boolean payrollId: string | null @@ -94,6 +92,45 @@ export const PayrollListPresentation = ({ } } + const buildSkipPayrollMenuAction = ( + payrollUuid: string, + payPeriod: PresentationPayroll['payPeriod'], + isProcessingSkipPayroll: boolean, + ): DataViewMenuAction | null => { + const { fullPeriod: payPeriodString } = formatPayPeriod( + payPeriod?.startDate, + payPeriod?.endDate, + ) + + const todayDateString = formatDateToStringDate(new Date()) + const todayAtMidnight = todayDateString ? new Date(todayDateString) : null + const payPeriodStartDate = payPeriod?.startDate ? new Date(payPeriod.startDate) : null + + const canSkipPayroll = + blockers.length === 0 && + todayAtMidnight && + payPeriodStartDate && + todayAtMidnight >= payPeriodStartDate + + if (!canSkipPayroll) { + return null + } + + return { + type: 'menu', + items: [ + { + label: t('skipPayrollCta'), + onClick: () => { + handleOpenSkipDialog(payrollUuid, payPeriodString) + }, + }, + ], + menuLabel: t('payrollMenuLabel'), + isLoading: isProcessingSkipPayroll, + } + } + const payScheduleNames = useMemo(() => { return paySchedules.reduce>((acc, schedule) => { acc[schedule.uuid] = schedule.name || schedule.customName || '' @@ -170,92 +207,47 @@ export const PayrollListPresentation = ({ {processed ? t('status.processed') : t('status.unprocessed')} ), }, - { - title: '', - align: 'right', - render: ({ payrollUuid, calculatedAt, processed, payPeriod }) => { - if (processed) { - return null - } - - const isProcessingSkipPayroll = skippingPayrollId === payrollUuid - - const button = calculatedAt ? ( - - ) : ( - - ) - - return isDesktop ? ( - button - ) : ( - - {button} - - ) - }, - }, ]} data={payrolls} label={t('payrollsListLabel')} - itemMenu={({ payrollUuid, processed, payPeriod }) => { - if (processed) { - return null - } - - const isProcessingSkipPayroll = skippingPayrollId === payrollUuid - - const { fullPeriod: payPeriodString } = formatPayPeriod( - payPeriod?.startDate, - payPeriod?.endDate, - ) - - const todayDateString = formatDateToStringDate(new Date()) - const todayAtMidnight = todayDateString ? new Date(todayDateString) : null - const payPeriodStartDate = payPeriod?.startDate ? new Date(payPeriod.startDate) : null + rowActions={{ + header: '', + align: 'right', + buttons: (item: PresentationPayroll) => { + if (item.processed || !item.payrollUuid) { + return [] + } - const canSkipPayroll = - blockers.length === 0 && - todayAtMidnight && - payPeriodStartDate && - todayAtMidnight >= payPeriodStartDate - - if (!canSkipPayroll) { - return null - } - - return ( - { - handleOpenSkipDialog(payrollUuid!, payPeriodString) - }, + const isProcessingSkipPayroll = skippingPayrollId === item.payrollUuid + return [ + { + type: 'button', + label: item.calculatedAt ? t('submitPayrollCta') : t('runPayrollTitle'), + onClick: () => { + if (item.calculatedAt) { + onSubmitPayroll({ payrollUuid: item.payrollUuid!, payPeriod: item.payPeriod }) + } else { + onRunPayroll({ payrollUuid: item.payrollUuid!, payPeriod: item.payPeriod }) + } + }, + buttonProps: { + variant: 'secondary' as const, + isLoading: isProcessingSkipPayroll, }, - ]} - /> - ) + }, + ] + }, + menuItems: (item: PresentationPayroll) => { + if (item.processed || !item.payrollUuid) { + return null + } + + return buildSkipPayrollMenuAction( + item.payrollUuid, + item.payPeriod, + skippingPayrollId === item.payrollUuid, + ) + }, }} /> Date: Sun, 30 Nov 2025 15:06:15 -0500 Subject: [PATCH 4/7] fix: convert css properties to use toRem --- .../Common/DataView/DataActions/DataViewActions.module.scss | 4 ++-- .../Common/DataView/DataCards/DataCards.module.scss | 4 ++-- .../Common/DataView/DataTable/DataTable.module.scss | 4 ++-- .../PayrollConfigurationPresentation.module.scss | 2 +- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/components/Common/DataView/DataActions/DataViewActions.module.scss b/src/components/Common/DataView/DataActions/DataViewActions.module.scss index 931fc6447..786c4da2c 100644 --- a/src/components/Common/DataView/DataActions/DataViewActions.module.scss +++ b/src/components/Common/DataView/DataActions/DataViewActions.module.scss @@ -2,7 +2,7 @@ display: inline-flex; align-items: center; justify-content: flex-end; - gap: 0.5rem; + gap: toRem(8); width: 100%; } @@ -10,6 +10,6 @@ display: flex; flex-direction: column; align-items: stretch; - gap: 0.5rem; + gap: toRem(8); width: 100%; } diff --git a/src/components/Common/DataView/DataCards/DataCards.module.scss b/src/components/Common/DataView/DataCards/DataCards.module.scss index 9db7dbf44..040a2d1e6 100644 --- a/src/components/Common/DataView/DataCards/DataCards.module.scss +++ b/src/components/Common/DataView/DataCards/DataCards.module.scss @@ -26,7 +26,7 @@ h5.columnTitle { width: 100%; display: flex; flex-direction: column; - gap: 0.125rem; + gap: toRem(2); } .footerSecondary { @@ -37,6 +37,6 @@ h5.columnTitle { .menuWrapper { display: flex; flex-direction: column; - gap: 0.5rem; + gap: toRem(8); align-items: flex-end; } diff --git a/src/components/Common/DataView/DataTable/DataTable.module.scss b/src/components/Common/DataView/DataTable/DataTable.module.scss index 5b0f0aa13..68eeb671a 100644 --- a/src/components/Common/DataView/DataTable/DataTable.module.scss +++ b/src/components/Common/DataView/DataTable/DataTable.module.scss @@ -14,7 +14,7 @@ .cellContent { display: flex; flex-direction: column; - gap: 0.125rem; + gap: toRem(2); } .cellSecondary { @@ -39,7 +39,7 @@ text-align: left; display: flex; flex-direction: column; - gap: 0.125rem; + gap: toRem(2); } .footerCell[data-align='center'] { diff --git a/src/components/Payroll/PayrollConfiguration/PayrollConfigurationPresentation.module.scss b/src/components/Payroll/PayrollConfiguration/PayrollConfigurationPresentation.module.scss index 22f897a33..8a99d3258 100644 --- a/src/components/Payroll/PayrollConfiguration/PayrollConfigurationPresentation.module.scss +++ b/src/components/Payroll/PayrollConfiguration/PayrollConfigurationPresentation.module.scss @@ -8,5 +8,5 @@ } .excludedBadge { - margin-top: 0.25rem; + margin-top: toRem(4); } From c1316f5a08891aac923be2d058a8f0c6512cad7f Mon Sep 17 00:00:00 2001 From: Aaron Lee Date: Fri, 19 Dec 2025 10:10:52 -0500 Subject: [PATCH 5/7] fix: remove changes from previous commit that rendered table actions through props --- .../DataActions/DataViewActions.module.scss | 15 -- .../DataView/DataActions/DataViewActions.tsx | 37 ---- .../DataView/DataCards/DataCards.module.scss | 7 - .../Common/DataView/DataCards/DataCards.tsx | 77 +++----- .../DataView/DataTable/DataTable.test.tsx | 27 --- .../Common/DataView/DataTable/DataTable.tsx | 40 ----- .../Common/DataView/DataView.stories.tsx | 39 ++--- src/components/Common/DataView/DataView.tsx | 10 +- src/components/Common/DataView/useDataView.ts | 31 +--- .../PayrollList/PayrollListPresentation.tsx | 164 +++++++++--------- 10 files changed, 133 insertions(+), 314 deletions(-) delete mode 100644 src/components/Common/DataView/DataActions/DataViewActions.module.scss delete mode 100644 src/components/Common/DataView/DataActions/DataViewActions.tsx diff --git a/src/components/Common/DataView/DataActions/DataViewActions.module.scss b/src/components/Common/DataView/DataActions/DataViewActions.module.scss deleted file mode 100644 index 786c4da2c..000000000 --- a/src/components/Common/DataView/DataActions/DataViewActions.module.scss +++ /dev/null @@ -1,15 +0,0 @@ -.actionsRow { - display: inline-flex; - align-items: center; - justify-content: flex-end; - gap: toRem(8); - width: 100%; -} - -.actionsColumn { - display: flex; - flex-direction: column; - align-items: stretch; - gap: toRem(8); - width: 100%; -} diff --git a/src/components/Common/DataView/DataActions/DataViewActions.tsx b/src/components/Common/DataView/DataActions/DataViewActions.tsx deleted file mode 100644 index bd42120ef..000000000 --- a/src/components/Common/DataView/DataActions/DataViewActions.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import type { DataViewAction } from '../useDataView' -import styles from './DataViewActions.module.scss' -import { HamburgerMenu } from '@/components/Common/HamburgerMenu' -import { useComponentContext } from '@/contexts/ComponentAdapter/useComponentContext' - -type DataViewActionsProps = { - actions: DataViewAction[] - orientation: 'row' | 'column' -} - -export const DataViewActions = ({ actions, orientation }: DataViewActionsProps) => { - const Components = useComponentContext() - - if (actions.length === 0) { - return null - } - - const containerClass = orientation === 'row' ? styles.actionsRow : styles.actionsColumn - - return ( -
- {actions.map((action, index) => { - if (action.type === 'button') { - const { label, onClick, buttonProps } = action - return ( - - {label} - - ) - } - - const { items, ...menuProps } = action - return - })} -
- ) -} diff --git a/src/components/Common/DataView/DataCards/DataCards.module.scss b/src/components/Common/DataView/DataCards/DataCards.module.scss index 040a2d1e6..a539d10f8 100644 --- a/src/components/Common/DataView/DataCards/DataCards.module.scss +++ b/src/components/Common/DataView/DataCards/DataCards.module.scss @@ -33,10 +33,3 @@ h5.columnTitle { color: var(--g-colorBodySubContent); font-size: var(--g-fontSizeSmall); } - -.menuWrapper { - display: flex; - flex-direction: column; - gap: toRem(8); - align-items: flex-end; -} diff --git a/src/components/Common/DataView/DataCards/DataCards.tsx b/src/components/Common/DataView/DataCards/DataCards.tsx index 541c4f0d7..e7795ee94 100644 --- a/src/components/Common/DataView/DataCards/DataCards.tsx +++ b/src/components/Common/DataView/DataCards/DataCards.tsx @@ -1,6 +1,5 @@ import { getColumnContent } from '../getColumnContent' import { getFooterContent } from '../getFooterContent' -import { DataViewActions } from '../DataActions/DataViewActions' import styles from './DataCards.module.scss' import type { useDataViewPropReturn } from '@/components/Common/DataView/useDataView' import { Flex } from '@/components/Common/Flex/Flex' @@ -13,7 +12,6 @@ export type DataCardsProps = { onSelect?: useDataViewPropReturn['onSelect'] emptyState?: useDataViewPropReturn['emptyState'] footer?: useDataViewPropReturn['footer'] - rowActions?: useDataViewPropReturn['rowActions'] } export const DataCards = ({ @@ -23,7 +21,6 @@ export const DataCards = ({ onSelect, emptyState, footer, - rowActions, }: DataCardsProps) => { const Components = useComponentContext() return ( @@ -33,57 +30,35 @@ export const DataCards = ({ {emptyState()}
)} - {data.map((item, index) => { - const inlineMenu = itemMenu?.(item) - const actionButtons = rowActions?.buttons?.(item) ?? [] - const actionMenu = rowActions?.menuItems?.(item) ?? null - - const cardMenu = - inlineMenu || actionMenu ? ( -
- {actionMenu && } - {inlineMenu} -
- ) : undefined - - return ( -
- { - onSelect(item, checked) - } - : undefined - } - > - {columns.map((column, index) => { - const { primary, secondary } = getColumnContent(item, column) - - return ( - - {column.title &&
{column.title}
} -
-
{primary}
- {secondary !== undefined && ( -
{secondary}
- )} -
-
- ) - })} - {actionButtons.length > 0 && ( - + {data.map((item, index) => ( +
+ { + onSelect(item, checked) + } + : undefined + } + > + {columns.map((column, index) => { + const { primary, secondary } = getColumnContent(item, column) + return ( + + {column.title &&
{column.title}
}
- +
{primary}
+ {secondary !== undefined && ( +
{secondary}
+ )}
- )} -
-
- ) - })} + ) + })} +
+
+ ))} {footer && (
diff --git a/src/components/Common/DataView/DataTable/DataTable.test.tsx b/src/components/Common/DataView/DataTable/DataTable.test.tsx index dbd158d04..83722a662 100644 --- a/src/components/Common/DataView/DataTable/DataTable.test.tsx +++ b/src/components/Common/DataView/DataTable/DataTable.test.tsx @@ -133,33 +133,6 @@ describe('DataTable Component', () => { expect(screen.getByText('55')).toBeInTheDocument() }) - test('should render row actions with buttons', async () => { - const onAction = vi.fn() - renderTable({ - data: testData, - columns: testColumns, - label: 'Actions Table', - rowActions: { - header: 'Actions', - align: 'right', - buttons: (item: MockData) => [ - { - type: 'button', - label: `Select ${item.name}`, - onClick: () => onAction(item.name), - buttonProps: { - variant: 'secondary', - }, - }, - ], - }, - }) - - const actionButton = screen.getByText('Select Alice') - await userEvent.click(actionButton) - expect(onAction).toHaveBeenCalledWith('Alice') - }) - test('should apply alignment when column specifies it', () => { const { container } = renderTable({ data: testData, diff --git a/src/components/Common/DataView/DataTable/DataTable.tsx b/src/components/Common/DataView/DataTable/DataTable.tsx index a7748ae76..fcaaec976 100644 --- a/src/components/Common/DataView/DataTable/DataTable.tsx +++ b/src/components/Common/DataView/DataTable/DataTable.tsx @@ -3,7 +3,6 @@ import type { useDataViewPropReturn } from '../useDataView' import type { TableData, TableRow, TableProps } from '../../UI/Table/TableTypes' import { VisuallyHidden } from '../../VisuallyHidden' import { getColumnContent } from '../getColumnContent' -import { DataViewActions } from '../DataActions/DataViewActions' import { getFooterContent } from '../getFooterContent' import styles from './DataTable.module.scss' import { useComponentContext } from '@/contexts/ComponentAdapter/useComponentContext' @@ -16,7 +15,6 @@ export type DataTableProps = { onSelect?: useDataViewPropReturn['onSelect'] emptyState?: useDataViewPropReturn['emptyState'] footer?: useDataViewPropReturn['footer'] - rowActions?: useDataViewPropReturn['rowActions'] variant?: TableProps['variant'] } @@ -28,7 +26,6 @@ export const DataTable = ({ onSelect, emptyState, footer, - rowActions, variant, }: DataTableProps) => { const Components = useComponentContext() @@ -62,18 +59,6 @@ export const DataTable = ({ }, ] : []), - ...(rowActions - ? [ - { - key: 'row-actions-header', - content: ( -
- {rowActions.header || ''} -
- ), - }, - ] - : []), ] const rows: TableRow[] = data.map((item, rowIndex) => { @@ -124,24 +109,6 @@ export const DataTable = ({ }, ] : []), - ...(rowActions - ? [ - { - key: `row-actions-${rowIndex}`, - content: ( -
- -
- ), - }, - ] - : []), ] return { @@ -192,13 +159,6 @@ export const DataTable = ({ }) } - if (rowActions) { - footerCells.push({ - key: 'footer-row-actions', - content:
, - }) - } - return footerCells } diff --git a/src/components/Common/DataView/DataView.stories.tsx b/src/components/Common/DataView/DataView.stories.tsx index 397afad1e..82116a86d 100644 --- a/src/components/Common/DataView/DataView.stories.tsx +++ b/src/components/Common/DataView/DataView.stories.tsx @@ -1,6 +1,7 @@ import { action } from '@ladle/react' import { DataView } from '@/components/Common/DataView/DataView' import { useDataView } from '@/components/Common/DataView/useDataView' +import { HamburgerMenu } from '@/components/Common/HamburgerMenu' import TrashCanSvg from '@/assets/icons/trashcan.svg?react' import PencilSvg from '@/assets/icons/pencil.svg?react' @@ -195,16 +196,15 @@ export const DataViewWithMenu = () => { { key: 'amount', title: 'Amount' }, { key: 'payTimePeriod', title: 'Pay Time Period' }, ], - rowActions: { - header: '', - align: 'right', - menuItems: () => ({ - type: 'menu', - items: [ - { label: 'Edit', icon: , onClick: () => {} }, - { label: 'Delete', icon: , onClick: () => {} }, - ], - }), + itemMenu: () => { + return ( + , onClick: () => {} }, + { label: 'Delete', icon: , onClick: () => {} }, + ]} + /> + ) }, }) @@ -220,16 +220,15 @@ export const DataViewSelectableWithMenu = () => { { key: 'amount', title: 'Amount' }, { key: 'payTimePeriod', title: 'Pay Time Period' }, ], - rowActions: { - header: '', - align: 'right', - menuItems: () => ({ - type: 'menu', - items: [ - { label: 'Edit', icon: , onClick: () => {} }, - { label: 'Delete', icon: , onClick: () => {} }, - ], - }), + itemMenu: () => { + return ( + , onClick: () => {} }, + { label: 'Delete', icon: , onClick: () => {} }, + ]} + /> + ) }, onSelect: (item, checked) => { action('onSelect')({ diff --git a/src/components/Common/DataView/DataView.tsx b/src/components/Common/DataView/DataView.tsx index 6e835e72d..532cbb0aa 100644 --- a/src/components/Common/DataView/DataView.tsx +++ b/src/components/Common/DataView/DataView.tsx @@ -21,7 +21,6 @@ export type DataViewProps = { isFetching?: boolean variant?: TableProps['variant'] emptyState?: useDataViewPropReturn['emptyState'] - rowActions?: useDataViewPropReturn['rowActions'] } export const DataView = ({ @@ -32,7 +31,6 @@ export const DataView = ({ footer, variant, emptyState, - rowActions, ...dataViewProps }: DataViewProps) => { const containerRef = useRef(null) @@ -58,13 +56,7 @@ export const DataView = ({ }} > {isBreakpointsDetected && ( - + )} {pagination && }
diff --git a/src/components/Common/DataView/useDataView.ts b/src/components/Common/DataView/useDataView.ts index 1c40bb629..e237ebd08 100644 --- a/src/components/Common/DataView/useDataView.ts +++ b/src/components/Common/DataView/useDataView.ts @@ -1,8 +1,6 @@ import { useMemo } from 'react' import type { ReactNode } from 'react' import type { PaginationControlProps } from '@/components/Common/PaginationControl/PaginationControlTypes' -import type { ButtonProps } from '@/components/Common/UI/Button/ButtonTypes' -import type { MenuItem } from '@/components/Common/UI/Menu/MenuTypes' type DataViewColumnBase = { title: string | React.ReactNode @@ -13,24 +11,6 @@ type DataViewColumnBase = { export type DataViewFooterCell = ReactNode | { primary: ReactNode; secondary?: ReactNode } -export type DataViewButtonAction = { - type: 'button' - label: ReactNode - onClick: () => void - buttonProps?: Partial -} - -export type DataViewMenuAction = { - type: 'menu' - items: MenuItem[] - triggerLabel?: string - menuLabel?: string - onClose?: () => void - isLoading?: boolean -} - -export type DataViewAction = DataViewButtonAction | DataViewMenuAction - export type DataViewColumn = | (DataViewColumnBase & { key: keyof T @@ -53,12 +33,6 @@ export type useDataViewProp = { emptyState?: () => React.ReactNode footer?: () => Partial, DataViewFooterCell>> isFetching?: boolean - rowActions?: { - header?: ReactNode - align?: 'left' | 'center' | 'right' - buttons?: (item: T) => DataViewButtonAction[] - menuItems?: (item: T) => DataViewMenuAction | null - } } export type useDataViewPropReturn = { @@ -70,7 +44,6 @@ export type useDataViewPropReturn = { emptyState?: () => React.ReactNode footer?: () => Partial, DataViewFooterCell>> isFetching?: boolean - rowActions?: NonNullable['rowActions']> } export const useDataView = ({ @@ -82,7 +55,6 @@ export const useDataView = ({ emptyState, footer, isFetching, - rowActions, }: useDataViewProp): useDataViewPropReturn => { const dataViewProps = useMemo(() => { return { @@ -94,9 +66,8 @@ export const useDataView = ({ emptyState, footer, isFetching, - rowActions, } - }, [pagination, data, columns, itemMenu, onSelect, emptyState, footer, isFetching, rowActions]) + }, [pagination, data, columns, itemMenu, onSelect, emptyState, footer, isFetching]) return dataViewProps } diff --git a/src/components/Payroll/PayrollList/PayrollListPresentation.tsx b/src/components/Payroll/PayrollList/PayrollListPresentation.tsx index 729f7dfc5..4e9f05ef7 100644 --- a/src/components/Payroll/PayrollList/PayrollListPresentation.tsx +++ b/src/components/Payroll/PayrollList/PayrollListPresentation.tsx @@ -6,13 +6,13 @@ import type { ApiPayrollBlocker } from '../PayrollBlocker/payrollHelpers' import { PayrollBlockerAlerts } from '../PayrollBlocker/components/PayrollBlockerAlerts' import type { PayrollType } from './types' import styles from './PayrollListPresentation.module.scss' -import { DataView, Flex } from '@/components/Common' +import { DataView, Flex, HamburgerMenu } from '@/components/Common' import { useComponentContext } from '@/contexts/ComponentAdapter/useComponentContext' import { useI18n } from '@/i18n' import { formatDateToStringDate } from '@/helpers/dateFormatting' import { useDateFormatter } from '@/hooks/useDateFormatter' import FeatureIconCheck from '@/assets/icons/feature-icon-check.svg?react' -import type { DataViewMenuAction } from '@/components/Common/DataView/useDataView' +import useContainerBreakpoints from '@/hooks/useContainerBreakpoints/useContainerBreakpoints' interface PresentationPayroll extends Payroll { payrollType: PayrollType @@ -43,11 +43,13 @@ export const PayrollListPresentation = ({ skippingPayrollId, blockers, }: PayrollListPresentationProps) => { - const { Badge, Dialog, Heading, Text, Alert } = useComponentContext() + const { Badge, Button, Dialog, Heading, Text, Alert } = useComponentContext() useI18n('Payroll.PayrollList') const { t } = useTranslation('Payroll.PayrollList') const dateFormatter = useDateFormatter() const containerRef = useRef(null) + const breakpoints = useContainerBreakpoints({ ref: containerRef }) + const isDesktop = breakpoints.includes('small') const [skipPayrollDialogState, setSkipPayrollDialogState] = useState<{ isOpen: boolean payrollId: string | null @@ -92,45 +94,6 @@ export const PayrollListPresentation = ({ } } - const buildSkipPayrollMenuAction = ( - payrollUuid: string, - payPeriod: PresentationPayroll['payPeriod'], - isProcessingSkipPayroll: boolean, - ): DataViewMenuAction | null => { - const { fullPeriod: payPeriodString } = formatPayPeriod( - payPeriod?.startDate, - payPeriod?.endDate, - ) - - const todayDateString = formatDateToStringDate(new Date()) - const todayAtMidnight = todayDateString ? new Date(todayDateString) : null - const payPeriodStartDate = payPeriod?.startDate ? new Date(payPeriod.startDate) : null - - const canSkipPayroll = - blockers.length === 0 && - todayAtMidnight && - payPeriodStartDate && - todayAtMidnight >= payPeriodStartDate - - if (!canSkipPayroll) { - return null - } - - return { - type: 'menu', - items: [ - { - label: t('skipPayrollCta'), - onClick: () => { - handleOpenSkipDialog(payrollUuid, payPeriodString) - }, - }, - ], - menuLabel: t('payrollMenuLabel'), - isLoading: isProcessingSkipPayroll, - } - } - const payScheduleNames = useMemo(() => { return paySchedules.reduce>((acc, schedule) => { acc[schedule.uuid] = schedule.name || schedule.customName || '' @@ -207,47 +170,92 @@ export const PayrollListPresentation = ({ {processed ? t('status.processed') : t('status.unprocessed')} ), }, + { + title: '', + align: 'right', + render: ({ payrollUuid, calculatedAt, processed, payPeriod }) => { + if (processed) { + return null + } + + const isProcessingSkipPayroll = skippingPayrollId === payrollUuid + + const button = calculatedAt ? ( + + ) : ( + + ) + + return isDesktop ? ( + button + ) : ( + + {button} + + ) + }, + }, ]} data={payrolls} label={t('payrollsListLabel')} - rowActions={{ - header: '', - align: 'right', - buttons: (item: PresentationPayroll) => { - if (item.processed || !item.payrollUuid) { - return [] - } + itemMenu={({ payrollUuid, processed, payPeriod }) => { + if (processed) { + return null + } - const isProcessingSkipPayroll = skippingPayrollId === item.payrollUuid - return [ - { - type: 'button', - label: item.calculatedAt ? t('submitPayrollCta') : t('runPayrollTitle'), - onClick: () => { - if (item.calculatedAt) { - onSubmitPayroll({ payrollUuid: item.payrollUuid!, payPeriod: item.payPeriod }) - } else { - onRunPayroll({ payrollUuid: item.payrollUuid!, payPeriod: item.payPeriod }) - } - }, - buttonProps: { - variant: 'secondary' as const, - isLoading: isProcessingSkipPayroll, - }, - }, - ] - }, - menuItems: (item: PresentationPayroll) => { - if (item.processed || !item.payrollUuid) { - return null - } + const isProcessingSkipPayroll = skippingPayrollId === payrollUuid - return buildSkipPayrollMenuAction( - item.payrollUuid, - item.payPeriod, - skippingPayrollId === item.payrollUuid, - ) - }, + const { fullPeriod: payPeriodString } = formatPayPeriod( + payPeriod?.startDate, + payPeriod?.endDate, + ) + + const todayDateString = formatDateToStringDate(new Date()) + const todayAtMidnight = todayDateString ? new Date(todayDateString) : null + const payPeriodStartDate = payPeriod?.startDate ? new Date(payPeriod.startDate) : null + + const canSkipPayroll = + blockers.length === 0 && + todayAtMidnight && + payPeriodStartDate && + todayAtMidnight >= payPeriodStartDate + + if (!canSkipPayroll) { + return null + } + + return ( + { + handleOpenSkipDialog(payrollUuid!, payPeriodString) + }, + }, + ]} + /> + ) }} /> Date: Fri, 19 Dec 2025 10:45:14 -0500 Subject: [PATCH 6/7] fix: reduce font weight of leading text in tables --- src/components/Common/UI/Text/Text.module.scss | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 { From a676695f8be71431576dea63ff0eddcb85d46a4b Mon Sep 17 00:00:00 2001 From: Aaron Lee Date: Fri, 19 Dec 2025 12:50:01 -0500 Subject: [PATCH 7/7] fix: remove spacing between table header and body --- src/components/Common/UI/Table/Table.module.scss | 7 ------- 1 file changed, 7 deletions(-) diff --git a/src/components/Common/UI/Table/Table.module.scss b/src/components/Common/UI/Table/Table.module.scss index eb65f055f..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;