diff --git a/src/components/Payroll/PayrollList/PayrollList.stories.tsx b/src/components/Payroll/PayrollList/PayrollList.stories.tsx index 957eff5d2..707d314ff 100644 --- a/src/components/Payroll/PayrollList/PayrollList.stories.tsx +++ b/src/components/Payroll/PayrollList/PayrollList.stories.tsx @@ -169,6 +169,76 @@ export const PayrollListWithBlockersStory = () => { ) } +export const NoKebabActionsAvailable = () => { + const tomorrow = new Date() + tomorrow.setDate(tomorrow.getDate() + 7) + const futureStartDate = tomorrow.toISOString().split('T')[0] + + return ( + + ) +} + export const PayrollListWithWireInStatusesStory = () => { const futureDeadline = new Date(Date.now() + 12 * 60 * 60 * 1000) const nearDeadline = new Date(Date.now() + 2 * 60 * 60 * 1000) diff --git a/src/components/Payroll/PayrollList/PayrollListPresentation.module.scss b/src/components/Payroll/PayrollList/PayrollListPresentation.module.scss index 31123feba..5424ccf9c 100644 --- a/src/components/Payroll/PayrollList/PayrollListPresentation.module.scss +++ b/src/components/Payroll/PayrollList/PayrollListPresentation.module.scss @@ -31,6 +31,10 @@ min-width: 0; } +.menuPlaceholder { + visibility: hidden; +} + .offCycleCtaButton { flex-shrink: 0; white-space: nowrap; diff --git a/src/components/Payroll/PayrollList/PayrollListPresentation.test.tsx b/src/components/Payroll/PayrollList/PayrollListPresentation.test.tsx index 1ed2d6296..72ebac1bb 100644 --- a/src/components/Payroll/PayrollList/PayrollListPresentation.test.tsx +++ b/src/components/Payroll/PayrollList/PayrollListPresentation.test.tsx @@ -131,8 +131,7 @@ describe('PayrollListPresentation', () => { renderWithProviders() await screen.findByRole('heading', { name: 'Upcoming payroll' }) - const hamburgerButton = screen.queryByRole('button', { name: /open menu/i }) - expect(hamburgerButton).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: /open menu/i })).not.toBeInTheDocument() }) it('does not show hamburger menu when pay period starts tomorrow', async () => { @@ -152,8 +151,7 @@ describe('PayrollListPresentation', () => { renderWithProviders() await screen.findByRole('heading', { name: 'Upcoming payroll' }) - const hamburgerButton = screen.queryByRole('button', { name: /open menu/i }) - expect(hamburgerButton).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: /open menu/i })).not.toBeInTheDocument() }) it('shows hamburger menu when pay period starts today', async () => { @@ -227,8 +225,7 @@ describe('PayrollListPresentation', () => { ) await screen.findByRole('heading', { name: 'Upcoming payroll' }) - const hamburgerButton = screen.queryByRole('button', { name: /open menu/i }) - expect(hamburgerButton).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: /open menu/i })).not.toBeInTheDocument() }) }) diff --git a/src/components/Payroll/PayrollList/PayrollListPresentation.tsx b/src/components/Payroll/PayrollList/PayrollListPresentation.tsx index 01aba2e28..015691356 100644 --- a/src/components/Payroll/PayrollList/PayrollListPresentation.tsx +++ b/src/components/Payroll/PayrollList/PayrollListPresentation.tsx @@ -2,7 +2,7 @@ import type { Payroll } from '@gusto/embedded-api/models/components/payroll' import { OffCycleReasonType } from '@gusto/embedded-api/models/components/payroll' import type { PayScheduleList } from '@gusto/embedded-api/models/components/payschedulelist' import type { WireInRequest } from '@gusto/embedded-api/models/components/wireinrequest' -import { useState, useRef } from 'react' +import { useState, useRef, useMemo } from 'react' import { useTranslation } from 'react-i18next' import type { ApiPayrollBlocker } from '../PayrollBlocker/payrollHelpers' import { PayrollStatusBadges } from '../PayrollStatusBadges' @@ -24,6 +24,35 @@ const CANCELLABLE_OFF_CYCLE_REASONS = new Set([ OffCycleReasonType.DismissedEmployee, ]) +function hasKebabActions( + payroll: Payroll, + blockers: ApiPayrollBlocker[], + todayAtMidnight: Date | null, +): boolean { + if (payroll.processed) return false + + const payPeriodStartDate = payroll.payPeriod?.startDate + ? new Date(payroll.payPeriod.startDate) + : null + + const isSkippablePayroll = + !payroll.offCycle || payroll.offCycleReason === OffCycleReasonType.TransitionFromOldPaySchedule + + const canSkipPayroll = + blockers.length === 0 && + isSkippablePayroll && + todayAtMidnight && + payPeriodStartDate && + todayAtMidnight >= payPeriodStartDate + + const canDeletePayroll = + payroll.offCycle && + !!payroll.offCycleReason && + CANCELLABLE_OFF_CYCLE_REASONS.has(payroll.offCycleReason) + + return !!(canSkipPayroll || canDeletePayroll) +} + interface PayrollListPresentationProps { onRunPayroll: ({ payrollUuid, payPeriod }: Pick) => void onSubmitPayroll: ({ payrollUuid, payPeriod }: Pick) => void @@ -63,13 +92,24 @@ export const PayrollListPresentation = ({ wireInRequests, dateRangeFilter, }: PayrollListPresentationProps) => { - const { Box, Button, Dialog, Heading, Text, Alert } = useComponentContext() + const { Box, Button, ButtonIcon, 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('large') + + const todayAtMidnight = useMemo(() => { + const todayDateString = formatDateToStringDate(new Date()) + return todayDateString ? new Date(todayDateString) : null + }, []) + + const anyPayrollHasKebabActions = useMemo( + () => payrolls.some(payroll => hasKebabActions(payroll, blockers, todayAtMidnight)), + [payrolls, blockers, todayAtMidnight], + ) + const [skipPayrollDialogState, setSkipPayrollDialogState] = useState<{ isOpen: boolean payrollId: string | null @@ -296,19 +336,32 @@ export const PayrollListPresentation = ({ itemMenu={payroll => { const { payrollUuid, processed, payPeriod } = payroll + const isProcessingSkipPayroll = skippingPayrollId === payrollUuid + const isProcessingDeletePayroll = deletingPayrollId === payrollUuid + + const button = isDesktop ? renderActionButton(payroll) : null + if (processed) { - return null + return ( +
+ {anyPayrollHasKebabActions && ( + + )} +
+ ) } - 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 const isSkippablePayroll = @@ -327,46 +380,48 @@ export const PayrollListPresentation = ({ !!payroll.offCycleReason && CANCELLABLE_OFF_CYCLE_REASONS.has(payroll.offCycleReason) - const button = isDesktop ? renderActionButton(payroll) : null - - const isProcessingDeletePayroll = deletingPayrollId === payrollUuid - - const hamburgerMenu = canSkipPayroll ? ( - { handleOpenSkipDialog(payrollUuid!, payPeriodString) }, }, - ]} - /> - ) : canDeletePayroll ? ( - { - handleOpenDeleteDialog(payrollUuid!, payPeriodString) + ] + : canDeletePayroll + ? [ + { + label: t('deletePayrollCta'), + onClick: () => { + handleOpenDeleteDialog(payrollUuid!, payPeriodString) + }, }, - }, - ]} - /> - ) : null + ] + : null - if (!button && !hamburgerMenu) { - return null - } + const hasMenuActions = menuItems !== null return (
{button} - {hamburgerMenu} + {hasMenuActions ? ( + + ) : ( + anyPayrollHasKebabActions && ( + + ) + )}
) }}