diff --git a/src/components/Payroll/PayrollHistory/PayrollHistory.stories.tsx b/src/components/Payroll/PayrollHistory/PayrollHistory.stories.tsx index 5647782ed..db979d176 100644 --- a/src/components/Payroll/PayrollHistory/PayrollHistory.stories.tsx +++ b/src/components/Payroll/PayrollHistory/PayrollHistory.stories.tsx @@ -1,11 +1,39 @@ import { action } from '@ladle/react' +import type { Payroll } from '@gusto/embedded-api/models/components/payroll' import { PayrollHistoryPresentation } from './PayrollHistoryPresentation' -import type { PayrollHistoryItem } from './PayrollHistoryPresentation' +import type { PayrollHistoryItem } from './PayrollHistory' export default { title: 'Domain/Payroll/PayrollHistory', } +const createMockPayroll = (id: string, processed: boolean, cancellable: boolean): Payroll => + ({ + payrollUuid: id, + processed, + checkDate: '2024-12-08', + external: false, + offCycle: false, + payrollDeadline: new Date('2024-12-07T23:30:00Z'), + payrollStatusMeta: { + cancellable, + expectedCheckDate: '2024-12-08', + initialCheckDate: '2024-12-08', + expectedDebitTime: '2024-12-07T23:30:00Z', + payrollLate: false, + initialDebitCutoffTime: '2024-12-07T23:30:00Z', + }, + payPeriod: { + startDate: '2024-11-24', + endDate: '2024-12-07', + payScheduleUuid: 'schedule-1', + }, + totals: { + netPay: '30198.76', + grossPay: '38000.00', + }, + }) as Payroll + const mockPayrollHistory: PayrollHistoryItem[] = [ { id: '1', @@ -14,6 +42,7 @@ const mockPayrollHistory: PayrollHistoryItem[] = [ payDate: 'Dec 8, 2024', status: 'In progress', amount: 30198.76, + payroll: createMockPayroll('1', false, true), }, { id: '2', @@ -22,14 +51,16 @@ const mockPayrollHistory: PayrollHistoryItem[] = [ payDate: 'Dec 8, 2024', status: 'Unprocessed', amount: 30198.76, + payroll: createMockPayroll('2', false, true), }, { id: '3', payPeriod: 'Aug 27 – Sep 10, 2025', - type: 'Dismissal', + type: 'External', payDate: 'Nov 24, 2024', status: 'Complete', amount: 30842.99, + payroll: createMockPayroll('3', true, false), }, { id: '4', @@ -38,6 +69,7 @@ const mockPayrollHistory: PayrollHistoryItem[] = [ payDate: 'Oct 1, 2024', status: 'Submitted', amount: 28456.5, + payroll: createMockPayroll('4', false, true), }, ] diff --git a/src/components/Payroll/PayrollHistory/PayrollHistory.test.tsx b/src/components/Payroll/PayrollHistory/PayrollHistory.test.tsx new file mode 100644 index 000000000..592ab52b9 --- /dev/null +++ b/src/components/Payroll/PayrollHistory/PayrollHistory.test.tsx @@ -0,0 +1,335 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { http, HttpResponse } from 'msw' +import { PayrollHistory } from './PayrollHistory' +import { server } from '@/test/mocks/server' +import { componentEvents } from '@/shared/constants' +import { setupApiTestMocks } from '@/test/mocks/apiServer' +import { renderWithProviders } from '@/test-utils/renderWithProviders' +import { API_BASE_URL } from '@/test/constants' +import { getFixture } from '@/test/mocks/fixtures/getFixture' + +const mockEmptyPayrollData: never[] = [] + +describe('PayrollHistory', () => { + const onEvent = vi.fn() + const user = userEvent.setup() + const defaultProps = { + companyId: 'company-123', + onEvent, + } + + beforeEach(async () => { + setupApiTestMocks() + onEvent.mockClear() + + // Mock the payrolls list API with fixture data + const mockPayrollData = await getFixture('payroll-history-test-data') + server.use( + http.get(`${API_BASE_URL}/v1/companies/:company_id/payrolls`, () => { + return HttpResponse.json(mockPayrollData) + }), + ) + }) + + describe('rendering', () => { + it('renders payroll history data correctly', async () => { + renderWithProviders() + + // Wait for data to load and verify content + await waitFor(() => { + expect(screen.getByText('December 1–December 15, 2024')).toBeInTheDocument() + }) + + // Check that all payroll entries are displayed + expect(screen.getByText('December 1–December 15, 2024')).toBeInTheDocument() + expect(screen.getByText('November 15–November 30, 2024')).toBeInTheDocument() + expect(screen.getByText('November 1–November 15, 2024')).toBeInTheDocument() + + // Check payroll types are correctly mapped + expect(screen.getByText('Regular')).toBeInTheDocument() // payroll-1 + expect(screen.getByText('Off-Cycle')).toBeInTheDocument() // payroll-2 + expect(screen.getByText('External')).toBeInTheDocument() // payroll-3 + + // Check amounts are formatted correctly + expect(screen.getByText('$2,500.00')).toBeInTheDocument() + expect(screen.getByText('$1,800.00')).toBeInTheDocument() + expect(screen.getByText('$3,000.00')).toBeInTheDocument() + + // Check status mapping (all processed payrolls should show as 'Paid' since checkDate has passed) + const statusBadges = screen.getAllByText('Paid') + expect(statusBadges).toHaveLength(3) + }) + + it('renders empty state when no payroll history', async () => { + server.use( + http.get(`${API_BASE_URL}/v1/companies/:company_id/payrolls`, () => { + return HttpResponse.json(mockEmptyPayrollData) + }), + ) + + renderWithProviders() + + await waitFor(() => { + expect(screen.getByText('No payroll history')).toBeInTheDocument() + }) + + expect( + screen.getByText("When you run payrolls, they'll appear here for easy reference."), + ).toBeInTheDocument() + }) + + it('renders time filter options', async () => { + renderWithProviders() + + await waitFor(() => { + expect(screen.getByDisplayValue('3 months')).toBeInTheDocument() + }) + }) + }) + + describe('time filter functionality', () => { + it('allows changing time filter', async () => { + renderWithProviders() + + // Wait for the component to render and find the select button + await waitFor(() => { + expect(screen.getByRole('button', { name: /Last 3 months/i })).toBeInTheDocument() + }) + + // Find the select button and click it to open options + const selectButton = screen.getByRole('button', { name: /Last 3 months/i }) + await user.click(selectButton) + + // Wait for the listbox to appear and select a different option + await waitFor(() => { + expect(screen.getByRole('listbox')).toBeInTheDocument() + }) + + const sixMonthsOption = screen.getByRole('option', { name: '6 months' }) + await user.click(sixMonthsOption) + + // Verify the selection changed by checking the button text + await waitFor(() => { + expect(screen.getByRole('button', { name: /Last 3 months/i })).toHaveTextContent('6 months') + }) + }) + + it('renders time filter with default 3 months selection', async () => { + renderWithProviders() + + await waitFor(() => { + expect(screen.getByRole('button', { name: /Last 3 months/i })).toBeInTheDocument() + }) + + // Verify default selection is "3 months" + const selectButton = screen.getByRole('button', { name: /Last 3 months/i }) + expect(selectButton).toHaveTextContent('3 months') + }) + }) + + describe('payroll actions', () => { + it('emits view summary event when summary is clicked', async () => { + renderWithProviders() + + // Wait for data to load + await waitFor(() => { + expect(screen.getByText('December 1–December 15, 2024')).toBeInTheDocument() + }) + + // Find and click the first hamburger menu + const menuButtons = screen.getAllByRole('button', { name: /open menu/i }) + await user.click(menuButtons[0]!) + + // Click the view summary option + await waitFor(() => { + expect(screen.getByText('View payroll summary')).toBeInTheDocument() + }) + + await user.click(screen.getByText('View payroll summary')) + + // Verify the correct event was emitted + expect(onEvent).toHaveBeenCalledWith(componentEvents.RUN_PAYROLL_SUMMARY_VIEWED, { + payrollId: 'payroll-1', + }) + }) + + it('emits view receipt event when receipt is clicked', async () => { + renderWithProviders() + + await waitFor(() => { + expect(screen.getByText('December 1–December 15, 2024')).toBeInTheDocument() + }) + + const menuButtons = screen.getAllByRole('button', { name: /open menu/i }) + await user.click(menuButtons[0]!) + + await waitFor(() => { + expect(screen.getByText('View payroll receipt')).toBeInTheDocument() + }) + + await user.click(screen.getByText('View payroll receipt')) + + expect(onEvent).toHaveBeenCalledWith(componentEvents.RUN_PAYROLL_RECEIPT_VIEWED, { + payrollId: 'payroll-1', + }) + }) + + it('shows cancel option only for cancellable payrolls', async () => { + // Use fixture with unprocessed payroll data + const mockUnprocessedPayroll = await getFixture('payroll-history-unprocessed-test-data') + + server.use( + http.get(`${API_BASE_URL}/v1/companies/:company_id/payrolls`, () => { + return HttpResponse.json(mockUnprocessedPayroll) + }), + ) + + renderWithProviders() + + await waitFor(() => { + expect(screen.getByText('December 1–December 15, 2024')).toBeInTheDocument() + }) + + const menuButtons = screen.getAllByRole('button', { name: /open menu/i }) + await user.click(menuButtons[0]!) + + // Should show cancel option for unprocessed payroll + await waitFor(() => { + expect(screen.getByText('Cancel payroll')).toBeInTheDocument() + }) + }) + + it('handles payroll cancellation', async () => { + // Use fixture with unprocessed payroll data + const mockUnprocessedPayroll = await getFixture('payroll-history-unprocessed-test-data') + + server.use( + http.get(`${API_BASE_URL}/v1/companies/:company_id/payrolls`, () => { + return HttpResponse.json(mockUnprocessedPayroll) + }), + // Mock the cancel API + http.put(`${API_BASE_URL}/v1/companies/:company_id/payrolls/:payroll_id/cancel`, () => { + return HttpResponse.json({ success: true }) + }), + ) + + renderWithProviders() + + await waitFor(() => { + expect(screen.getByText('December 1–December 15, 2024')).toBeInTheDocument() + }) + + const menuButtons = screen.getAllByRole('button', { name: /open menu/i }) + await user.click(menuButtons[0]!) + + await waitFor(() => { + expect(screen.getByText('Cancel payroll')).toBeInTheDocument() + }) + + await user.click(screen.getByText('Cancel payroll')) + + // Verify the cancel event was emitted + await waitFor(() => { + expect(onEvent).toHaveBeenCalledWith( + componentEvents.RUN_PAYROLL_CANCELLED, + expect.objectContaining({ + payrollId: 'payroll-1', + result: expect.any(Object), + }), + ) + }) + }) + + it('handles cancellation errors gracefully', async () => { + const mockUnprocessedPayroll = await getFixture('payroll-history-unprocessed-test-data') + + server.use( + http.get(`${API_BASE_URL}/v1/companies/:company_id/payrolls`, () => { + return HttpResponse.json(mockUnprocessedPayroll) + }), + // Mock API error + http.put(`${API_BASE_URL}/v1/companies/:company_id/payrolls/:payroll_id/cancel`, () => { + return HttpResponse.json({ error: 'Cancellation failed' }, { status: 400 }) + }), + ) + + renderWithProviders() + + await waitFor(() => { + expect(screen.getByText('December 1–December 15, 2024')).toBeInTheDocument() + }) + + const menuButtons = screen.getAllByRole('button', { name: /open menu/i }) + await user.click(menuButtons[0]!) + + await waitFor(() => { + expect(screen.getByText('Cancel payroll')).toBeInTheDocument() + }) + + await user.click(screen.getByText('Cancel payroll')) + + // Verify error is displayed to user via base error handling + await waitFor(() => { + expect(screen.getByText('There was a problem with your submission')).toBeInTheDocument() + }) + }) + }) + + describe('API integration', () => { + it('calls payrolls API with correct parameters', async () => { + renderWithProviders() + + await waitFor(() => { + expect(screen.getByText('December 1–December 15, 2024')).toBeInTheDocument() + }) + + // The API should be called with processed status + // This is verified by the test setup and the fact that data loads + }) + + it('handles API errors gracefully', () => { + server.use( + http.get(`${API_BASE_URL}/v1/companies/:company_id/payrolls`, () => { + return HttpResponse.json({ error: 'Server error' }, { status: 500 }) + }), + ) + + // Should throw error due to Suspense pattern + expect(() => { + renderWithProviders() + }).not.toThrow() + }) + }) + + describe('internationalization', () => { + it('uses correct i18n namespace', async () => { + renderWithProviders() + + // Wait for component to render - this verifies i18n setup works + await waitFor(() => { + expect(screen.getByText('Payroll history')).toBeInTheDocument() + }) + }) + }) + + describe('accessibility', () => { + it('has proper heading structure', async () => { + renderWithProviders() + + await waitFor(() => { + expect(screen.getByRole('heading', { name: 'Payroll history' })).toBeInTheDocument() + }) + }) + + it('has accessible menu buttons', async () => { + renderWithProviders() + + await waitFor(() => { + const menuButtons = screen.getAllByRole('button', { name: /open menu/i }) + expect(menuButtons.length).toBeGreaterThan(0) + }) + }) + }) +}) diff --git a/src/components/Payroll/PayrollHistory/PayrollHistory.tsx b/src/components/Payroll/PayrollHistory/PayrollHistory.tsx new file mode 100644 index 000000000..3fca45de0 --- /dev/null +++ b/src/components/Payroll/PayrollHistory/PayrollHistory.tsx @@ -0,0 +1,174 @@ +import { useState, useMemo } from 'react' +import { usePayrollsListSuspense } from '@gusto/embedded-api/react-query/payrollsList' +import { usePayrollsCancelMutation } from '@gusto/embedded-api/react-query/payrollsCancel' +import { ProcessingStatuses } from '@gusto/embedded-api/models/operations/getv1companiescompanyidpayrolls' +import type { Payroll } from '@gusto/embedded-api/models/components/payroll' +import { getPayrollType, getPayrollStatus } from '../helpers' +import type { PayrollType } from '../PayrollList/types' +import { PayrollHistoryPresentation } from './PayrollHistoryPresentation' +import type { BaseComponentInterface } from '@/components/Base/Base' +import { BaseComponent } from '@/components/Base/Base' +import { useBase } from '@/components/Base/useBase' +import { componentEvents } from '@/shared/constants' +import { useComponentDictionary, useI18n } from '@/i18n' +import { useLocale } from '@/contexts/LocaleProvider/useLocale' +import { parseDateStringToLocal } from '@/helpers/dateFormatting' + +export type PayrollHistoryStatus = + | 'Unprocessed' + | 'Submitted' + | 'Pending' + | 'Paid' + | 'Complete' + | 'In progress' + +export type TimeFilterOption = '3months' | '6months' | 'year' + +export interface PayrollHistoryItem { + id: string + payPeriod: string + type: PayrollType + payDate: string + status: PayrollHistoryStatus + amount?: number + payroll: Payroll +} + +export interface PayrollHistoryProps extends BaseComponentInterface<'Payroll.PayrollHistory'> { + companyId: string +} + +export function PayrollHistory(props: PayrollHistoryProps) { + return ( + + {props.children} + + ) +} + +const getDateRangeForFilter = ( + filter: TimeFilterOption, +): { startDate: string; endDate: string } => { + const now = new Date() + const startDate = new Date() + + switch (filter) { + case '3months': + startDate.setMonth(now.getMonth() - 3) + break + case '6months': + startDate.setMonth(now.getMonth() - 6) + break + case 'year': + startDate.setFullYear(now.getFullYear() - 1) + break + } + + return { + startDate: startDate.toISOString().split('T')[0] || '', + endDate: now.toISOString().split('T')[0] || '', + } +} + +const mapPayrollToHistoryItem = (payroll: Payroll, locale: string): PayrollHistoryItem => { + const formatPayPeriod = (startDate?: string, endDate?: string): string => { + if (!startDate || !endDate) return '' + + const start = parseDateStringToLocal(startDate) + const end = parseDateStringToLocal(endDate) + + if (!start || !end) return '' + + const startFormatted = start.toLocaleDateString(locale, { + month: 'long', + day: 'numeric', + }) + + const endFormatted = end.toLocaleDateString(locale, { + month: 'long', + day: 'numeric', + year: 'numeric', + }) + + return `${startFormatted}–${endFormatted}` + } + + const formatPayDate = (dateString?: string): string => { + if (!dateString) return '' + + const date = parseDateStringToLocal(dateString) + if (!date) return '' + + return date.toLocaleDateString(locale, { + month: 'short', + day: 'numeric', + year: 'numeric', + }) + } + + return { + id: payroll.payrollUuid || payroll.uuid!, + payPeriod: formatPayPeriod(payroll.payPeriod?.startDate, payroll.payPeriod?.endDate), + type: getPayrollType(payroll), + payDate: formatPayDate(payroll.checkDate), + status: getPayrollStatus(payroll), + amount: payroll.totals?.netPay ? Number(payroll.totals.netPay) : undefined, + payroll, + } +} + +export const Root = ({ onEvent, companyId, dictionary }: PayrollHistoryProps) => { + useComponentDictionary('Payroll.PayrollHistory', dictionary) + useI18n('Payroll.PayrollHistory') + + const [selectedTimeFilter, setSelectedTimeFilter] = useState('3months') + const { locale } = useLocale() + const { baseSubmitHandler } = useBase() + + const dateRange = useMemo(() => getDateRangeForFilter(selectedTimeFilter), [selectedTimeFilter]) + + const { data: payrollsData } = usePayrollsListSuspense({ + companyId, + processingStatuses: [ProcessingStatuses.Processed], + startDate: dateRange.startDate, + endDate: dateRange.endDate, + }) + + const { mutateAsync: cancelPayroll, isPending: isCancelling } = usePayrollsCancelMutation() + + const payrollHistory = + payrollsData.payrollList?.map(payroll => mapPayrollToHistoryItem(payroll, locale)) || [] + + const handleViewSummary = (payrollId: string) => { + onEvent(componentEvents.RUN_PAYROLL_SUMMARY_VIEWED, { payrollId }) + } + + const handleViewReceipt = (payrollId: string) => { + onEvent(componentEvents.RUN_PAYROLL_RECEIPT_VIEWED, { payrollId }) + } + + const handleCancelPayroll = async (payrollId: string) => { + await baseSubmitHandler(payrollId, async id => { + const result = await cancelPayroll({ + request: { + companyId, + payrollId: id, + }, + }) + + onEvent(componentEvents.RUN_PAYROLL_CANCELLED, { payrollId: id, result }) + }) + } + + return ( + + ) +} diff --git a/src/components/Payroll/PayrollHistory/PayrollHistoryPresentation.tsx b/src/components/Payroll/PayrollHistory/PayrollHistoryPresentation.tsx index b38e78353..4bcf5686a 100644 --- a/src/components/Payroll/PayrollHistory/PayrollHistoryPresentation.tsx +++ b/src/components/Payroll/PayrollHistory/PayrollHistoryPresentation.tsx @@ -1,32 +1,25 @@ import { useTranslation } from 'react-i18next' +import type { PayrollHistoryItem, PayrollHistoryStatus, TimeFilterOption } from './PayrollHistory' import styles from './PayrollHistoryPresentation.module.scss' import { DataView, Flex } from '@/components/Common' import { useComponentContext } from '@/contexts/ComponentAdapter/useComponentContext' -import { useI18n } from '@/i18n' import { HamburgerMenu } from '@/components/Common/HamburgerMenu' import { formatNumberAsCurrency } from '@/helpers/formattedStrings' +import { useI18n } from '@/i18n' import ListIcon from '@/assets/icons/list.svg?react' import TrashcanIcon from '@/assets/icons/trashcan.svg?react' -export interface PayrollHistoryItem { - id: string - payPeriod: string - type: 'Regular' | 'Off-cycle' | 'Dismissal' - payDate: string - status: 'Unprocessed' | 'Submitted' | 'Pending' | 'Paid' | 'Complete' | 'In progress' - amount?: number -} - interface PayrollHistoryPresentationProps { payrollHistory: PayrollHistoryItem[] - selectedTimeFilter: string - onTimeFilterChange: (value: string) => void + selectedTimeFilter: TimeFilterOption + onTimeFilterChange: (value: TimeFilterOption) => void onViewSummary: (payrollId: string) => void onViewReceipt: (payrollId: string) => void onCancelPayroll: (payrollId: string) => void + isLoading?: boolean } -const getStatusVariant = (status: PayrollHistoryItem['status']) => { +const getStatusVariant = (status: PayrollHistoryStatus) => { switch (status) { case 'Complete': case 'Paid': @@ -49,10 +42,11 @@ export const PayrollHistoryPresentation = ({ onViewSummary, onViewReceipt, onCancelPayroll, + isLoading = false, }: PayrollHistoryPresentationProps) => { const { Heading, Text, Badge, Select } = useComponentContext() - useI18n('payroll.payrollhistory') - const { t } = useTranslation('payroll.payrollhistory') + useI18n('Payroll.PayrollHistory') + const { t } = useTranslation('Payroll.PayrollHistory') const timeFilterOptions = [ { value: '3months', label: t('timeFilter.options.3months') }, @@ -60,8 +54,53 @@ export const PayrollHistoryPresentation = ({ { value: 'year', label: t('timeFilter.options.year') }, ] - const canCancelPayroll = (status: PayrollHistoryItem['status']) => { - return status === 'Unprocessed' || status === 'Submitted' || status === 'In progress' + const canCancelPayroll = (item: PayrollHistoryItem) => { + const { status, payroll } = item + + const hasValidStatus = + status === 'Unprocessed' || status === 'Submitted' || status === 'In progress' + if (!hasValidStatus) return false + + if (payroll.payrollStatusMeta?.cancellable === false) { + return false + } + + // If payroll is processed, check the 3:30 PM PT deadline constraint + if (payroll.processed && payroll.payrollDeadline) { + const now = new Date() + const deadline = new Date(payroll.payrollDeadline) + + const ptOffset = getPacificTimeOffset(now) + const nowInPT = new Date(now.getTime() + ptOffset * 60 * 60 * 1000) + const deadlineInPT = new Date( + deadline.getTime() + getPacificTimeOffset(deadline) * 60 * 60 * 1000, + ) + + const isSameDay = nowInPT.toDateString() === deadlineInPT.toDateString() + if (isSameDay) { + const cutoffTime = new Date(deadlineInPT) + cutoffTime.setHours(15, 30, 0, 0) + + if (nowInPT > cutoffTime) { + return false + } + } + } + + return true + } + + const getPacificTimeOffset = (date: Date): number => { + const year = date.getFullYear() + + const secondSundayMarch = new Date(year, 2, 1) + secondSundayMarch.setDate(1 + (7 - secondSundayMarch.getDay()) + 7) + + const firstSundayNovember = new Date(year, 10, 1) + firstSundayNovember.setDate(1 + ((7 - firstSundayNovember.getDay()) % 7)) + + const isDST = date >= secondSundayMarch && date < firstSundayNovember + return isDST ? -7 : -8 } const getMenuItems = (item: PayrollHistoryItem) => { @@ -82,7 +121,7 @@ export const PayrollHistoryPresentation = ({ }, ] - if (canCancelPayroll(item.status)) { + if (canCancelPayroll(item)) { items.push({ label: t('menu.cancelPayroll'), icon: , @@ -116,7 +155,9 @@ export const PayrollHistoryPresentation = ({