Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 70 additions & 0 deletions src/components/Payroll/PayrollList/PayrollList.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<PayrollListPresentation
payrolls={[
{
checkDate: '2025-12-12',
payrollDeadline: new Date(),
payrollUuid: 'no-skip-1',
payPeriod: {
payScheduleUuid: '1234',
startDate: futureStartDate,
endDate: '2025-12-31',
},
},
{
checkDate: '2025-12-20',
payrollDeadline: new Date(),
payrollUuid: 'no-skip-future-2',
payPeriod: {
payScheduleUuid: '1234',
startDate: futureStartDate,
endDate: '2025-12-31',
},
},
{
checkDate: '2025-12-25',
payrollDeadline: new Date(),
payrollUuid: 'no-skip-2',
offCycle: true,
payPeriod: {
payScheduleUuid: '1234',
startDate: futureStartDate,
endDate: '2025-12-31',
},
},
{
checkDate: '2025-12-30',
payrollDeadline: new Date(),
payrollUuid: 'processed-1',
processed: true,
payPeriod: {
payScheduleUuid: '1234',
startDate: '2025-01-01',
endDate: '2025-01-13',
},
},
]}
paySchedules={[{ uuid: '1234', version: '1', customName: 'Bi-weekly' }]}
onRunPayroll={runPayrollAction}
onSubmitPayroll={submitPayrollAction}
onSkipPayroll={skipPayrollAction}
onDeletePayroll={deletePayrollAction}
onRunOffCyclePayroll={runOffCyclePayrollAction}
showSkipSuccessAlert={false}
onDismissSkipSuccessAlert={dismissAlertAction}
showDeleteSuccessAlert={false}
onDismissDeleteSuccessAlert={dismissAlertAction}
blockers={[]}
skippingPayrollId={null}
deletingPayrollId={null}
wireInRequests={[]}
dateRangeFilter={mockDateRangeFilter}
/>
)
}

export const PayrollListWithWireInStatusesStory = () => {
const futureDeadline = new Date(Date.now() + 12 * 60 * 60 * 1000)
const nearDeadline = new Date(Date.now() + 2 * 60 * 60 * 1000)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,10 @@
min-width: 0;
}

.menuPlaceholder {
visibility: hidden;
}

.offCycleCtaButton {
flex-shrink: 0;
white-space: nowrap;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -131,8 +131,7 @@ describe('PayrollListPresentation', () => {
renderWithProviders(<PayrollListPresentation {...defaultProps} blockers={mockBlockers} />)

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 () => {
Expand All @@ -152,8 +151,7 @@ describe('PayrollListPresentation', () => {
renderWithProviders(<PayrollListPresentation {...defaultProps} payrolls={[futurePayroll]} />)

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 () => {
Expand Down Expand Up @@ -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()
})
})

Expand Down
125 changes: 90 additions & 35 deletions src/components/Payroll/PayrollList/PayrollListPresentation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -24,6 +24,35 @@ const CANCELLABLE_OFF_CYCLE_REASONS = new Set<string>([
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<Payroll, 'payrollUuid' | 'payPeriod'>) => void
onSubmitPayroll: ({ payrollUuid, payPeriod }: Pick<Payroll, 'payrollUuid' | 'payPeriod'>) => void
Expand Down Expand Up @@ -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<HTMLDivElement>(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
Expand Down Expand Up @@ -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 (
<div className={styles.actionsContainer}>
{anyPayrollHasKebabActions && (
<ButtonIcon
aria-label=""
aria-hidden={true}
tabIndex={-1}
isDisabled={true}
className={styles.menuPlaceholder}
/>
)}
</div>
)
}

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 =
Expand All @@ -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 ? (
<HamburgerMenu
isLoading={isProcessingSkipPayroll}
menuLabel={t('payrollMenuLabel')}
items={[
const menuItems = canSkipPayroll
? [
{
label: t('skipPayrollCta'),
onClick: () => {
handleOpenSkipDialog(payrollUuid!, payPeriodString)
},
},
]}
/>
) : canDeletePayroll ? (
<HamburgerMenu
isLoading={isProcessingDeletePayroll}
menuLabel={t('payrollMenuLabel')}
items={[
{
label: t('deletePayrollCta'),
onClick: () => {
handleOpenDeleteDialog(payrollUuid!, payPeriodString)
]
: canDeletePayroll
? [
{
label: t('deletePayrollCta'),
onClick: () => {
handleOpenDeleteDialog(payrollUuid!, payPeriodString)
},
},
},
]}
/>
) : null
]
: null

if (!button && !hamburgerMenu) {
return null
}
const hasMenuActions = menuItems !== null

return (
<div className={styles.actionsContainer}>
{button}
{hamburgerMenu}
{hasMenuActions ? (
<HamburgerMenu
isLoading={canSkipPayroll ? isProcessingSkipPayroll : isProcessingDeletePayroll}
menuLabel={t('payrollMenuLabel')}
items={menuItems}
/>
) : (
anyPayrollHasKebabActions && (
<ButtonIcon
aria-label=""
aria-hidden={true}
tabIndex={-1}
isDisabled={true}
className={styles.menuPlaceholder}
/>
)
)}
</div>
)
}}
Expand Down
Loading