diff --git a/docs/reference/endpoint-inventory.json b/docs/reference/endpoint-inventory.json index 526b5c137..4e9ff632e 100644 --- a/docs/reference/endpoint-inventory.json +++ b/docs/reference/endpoint-inventory.json @@ -517,6 +517,31 @@ "contractorPaymentUuid" ] }, + "Contractor.CreateHistoricalPayment": { + "endpoints": [ + { + "method": "GET", + "path": "/v1/companies/:companyUuid/contractors" + }, + { + "method": "POST", + "path": "/v1/companies/:companyId/contractor_payment_groups" + }, + { + "method": "POST", + "path": "/v1/companies/:companyId/contractor_payment_groups/preview" + }, + { + "method": "GET", + "path": "/v1/contractor_payment_groups/:contractorPaymentGroupUuid" + } + ], + "variables": [ + "companyId", + "companyUuid", + "contractorPaymentGroupUuid" + ] + }, "Employee.EmployeeList": { "endpoints": [ { diff --git a/docs/reference/endpoint-reference.md b/docs/reference/endpoint-reference.md index d6ac3a31a..116d4c716 100644 --- a/docs/reference/endpoint-reference.md +++ b/docs/reference/endpoint-reference.md @@ -101,6 +101,10 @@ import inventory from '@gusto/embedded-react-sdk/endpoint-inventory.json' | **Contractor.PaymentStatement** | GET | `/v1/contractor_payment_groups/:contractorPaymentGroupUuid` | | | GET | `/v1/companies/:companyUuid/contractors` | | | GET | `/v1/contractor_payments/:contractorPaymentUuid/receipt` | +| **Contractor.CreateHistoricalPayment** | GET | `/v1/companies/:companyUuid/contractors` | +| | POST | `/v1/companies/:companyId/contractor_payment_groups` | +| | POST | `/v1/companies/:companyId/contractor_payment_groups/preview` | +| | GET | `/v1/contractor_payment_groups/:contractorPaymentGroupUuid` | ## Employee components diff --git a/sdk-app/src/generated-registry-data.ts b/sdk-app/src/generated-registry-data.ts index fd364ae6a..9a1ca9379 100644 --- a/sdk-app/src/generated-registry-data.ts +++ b/sdk-app/src/generated-registry-data.ts @@ -22,7 +22,9 @@ export const ENTITY_REQUIREMENTS: Record = { 'Contractor.ContractorList': ['companyId'], 'Contractor.ContractorProfile': ['companyId'], 'Contractor.ContractorSubmit': ['contractorId'], + 'Contractor.CreateHistoricalPayment': ['companyId'], 'Contractor.CreatePayment': ['companyId'], + 'Contractor.HistoricalPaymentFlow': ['companyId'], 'Contractor.NewHireReport': ['contractorId'], 'Contractor.OnboardingFlow': ['companyId'], 'Contractor.PaymentFlow': ['companyId'], @@ -39,6 +41,7 @@ export const ENTITY_REQUIREMENTS: Record = { 'Employee.EmploymentEligibility': ['employeeId'], 'Employee.FederalTaxes': ['employeeId'], 'Employee.Landing': ['employeeId', 'companyId'], + 'Employee.ManagementEmployeeList': ['companyId'], 'Employee.OnboardingFlow': ['companyId'], 'Employee.OnboardingSummary': ['employeeId'], 'Employee.PaymentMethod': ['employeeId'], diff --git a/src/components/Contractor/Payments/HistoricalPayments/CreateHistoricalPayment/CreateHistoricalPayment.tsx b/src/components/Contractor/Payments/HistoricalPayments/CreateHistoricalPayment/CreateHistoricalPayment.tsx new file mode 100644 index 000000000..a5cfe1cf4 --- /dev/null +++ b/src/components/Contractor/Payments/HistoricalPayments/CreateHistoricalPayment/CreateHistoricalPayment.tsx @@ -0,0 +1,313 @@ +import { useContractorsListSuspense } from '@gusto/embedded-api/react-query/contractorsList' +import { useContractorPaymentGroupsCreateMutation } from '@gusto/embedded-api/react-query/contractorPaymentGroupsCreate' +import type { + ContractorPayments, + PostV1CompaniesCompanyIdContractorPaymentGroupsRequestBody, +} from '@gusto/embedded-api/models/operations/postv1companiescompanyidcontractorpaymentgroups' +import { useContractorPaymentGroupsPreviewMutation } from '@gusto/embedded-api/react-query/contractorPaymentGroupsPreview' +import { useMemo, useState } from 'react' +import DOMPurify from 'dompurify' +import { RFCDate } from '@gusto/embedded-api/types/rfcdate' +import { useForm } from 'react-hook-form' +import { zodResolver } from '@hookform/resolvers/zod' +import { useTranslation } from 'react-i18next' +import type { ContractorPaymentGroupPreview } from '@gusto/embedded-api/models/components/contractorpaymentgrouppreview' +import type { InternalAlert } from '../types' +import { CreateHistoricalPaymentPresentation } from './CreateHistoricalPaymentPresentation' +import { EditHistoricalPaymentPresentation } from './EditHistoricalPaymentPresentation' +import { + createEditHistoricalPaymentFormSchema, + type EditHistoricalPaymentFormValues, +} from './EditHistoricalPaymentFormSchema' +import { HistoricalPreviewPresentation } from './HistoricalPreviewPresentation' +import { calculateDefaultHistoricalDate } from './helpers' +import { useComponentDictionary } from '@/i18n' +import { BaseComponent, useBase, type BaseComponentInterface } from '@/components/Base' +import { componentEvents, ContractorOnboardingStatus } from '@/shared/constants' +import { firstLastName } from '@/helpers/formattedStrings' + +const HISTORICAL_PAYMENT_METHOD = 'Historical Payment' + +interface CreateHistoricalPaymentProps extends BaseComponentInterface<'Contractor.Payments.HistoricalPayments.CreateHistoricalPayment'> { + companyId: string +} + +export function CreateHistoricalPayment(props: CreateHistoricalPaymentProps) { + return ( + + {props.children} + + ) +} + +export const Root = ({ companyId, dictionary, onEvent }: CreateHistoricalPaymentProps) => { + useComponentDictionary( + 'Contractor.Payments.HistoricalPayments.CreateHistoricalPayment', + dictionary, + ) + const { t } = useTranslation('Contractor.Payments.HistoricalPayments.CreateHistoricalPayment') + const [isModalOpen, setIsModalOpen] = useState(false) + const { baseSubmitHandler } = useBase() + const [alerts, setAlerts] = useState>({}) + const [previewData, setPreviewData] = useState(null) + + const { mutateAsync: createContractorPaymentGroup, isPending: isCreatingContractorPaymentGroup } = + useContractorPaymentGroupsCreateMutation() + const { + mutateAsync: previewContractorPaymentGroup, + isPending: isPreviewingContractorPaymentGroup, + } = useContractorPaymentGroupsPreviewMutation() + + const { data: contractorList } = useContractorsListSuspense({ companyUuid: companyId }) + const contractors = (contractorList.contractors || []).filter( + contractor => + contractor.isActive && + contractor.onboardingStatus === ContractorOnboardingStatus.ONBOARDING_COMPLETED, + ) + + const [paymentDate, setPaymentDate] = useState(calculateDefaultHistoricalDate()) + + const initialContractorPayments: (ContractorPayments & { isTouched: boolean })[] = useMemo( + () => + contractors.map(contractor => ({ + contractorUuid: contractor.uuid, + paymentMethod: HISTORICAL_PAYMENT_METHOD, + wage: '0', + hours: '0', + bonus: '0', + reimbursement: '0', + isTouched: false, + })), + [contractors], + ) + const [virtualContractorPayments, setVirtualContractorPayments] = + useState(initialContractorPayments) + const totals = useMemo( + () => + virtualContractorPayments.reduce( + (acc, payment) => { + const contractor = contractors.find(c => c.uuid === payment.contractorUuid) + const isHourly = contractor?.wageType === 'Hourly' + const hours = Number(payment.hours || '0') + const wage = Number(payment.wage || '0') + const bonus = Number(payment.bonus || '0') + const reimbursement = Number(payment.reimbursement || '0') + const hourlyAmount = isHourly ? hours * Number(contractor.hourlyRate || '0') : 0 + const fixedWage = isHourly ? 0 : wage + + return { + wage: acc.wage + fixedWage, + bonus: acc.bonus + bonus, + reimbursement: acc.reimbursement + reimbursement, + total: acc.total + hourlyAmount + fixedWage + bonus + reimbursement, + } + }, + { wage: 0, bonus: 0, reimbursement: 0, total: 0 }, + ), + [virtualContractorPayments, contractors], + ) + + const formMethods = useForm({ + resolver: zodResolver(createEditHistoricalPaymentFormSchema()), + defaultValues: { + wageType: 'Hourly', + hours: 0, + wage: 0, + bonus: 0, + reimbursement: 0, + hourlyRate: 0, + contractorUuid: '', + }, + }) + + const onCreatePaymentGroup = async () => { + await baseSubmitHandler(null, async () => { + const contractorPayments = virtualContractorPayments.filter(payment => payment.isTouched) + if (contractorPayments.length === 0 || !previewData?.creationToken) { + return + } + const creationToken = previewData.creationToken + + const requestBody: PostV1CompaniesCompanyIdContractorPaymentGroupsRequestBody = { + checkDate: new RFCDate(paymentDate), + contractorPayments: contractorPayments.map(({ isTouched, ...rest }) => rest), + creationToken, + } + + const response = await createContractorPaymentGroup({ + request: { + companyId, + requestBody, + }, + }) + + onEvent( + componentEvents.CONTRACTOR_HISTORICAL_PAYMENT_CREATED, + response.contractorPaymentGroup || {}, + ) + }) + } + + const onEditContractor = (contractorUuid: string) => { + const contractor = contractors.find(contractor => contractor.uuid === contractorUuid) + const contractorPayment = virtualContractorPayments.find( + payment => payment.contractorUuid === contractorUuid, + ) + + formMethods.reset( + { + wageType: contractor?.wageType || 'Hourly', + hours: Number(contractorPayment?.hours || '0'), + wage: Number(contractorPayment?.wage || '0'), + bonus: Number(contractorPayment?.bonus || '0'), + reimbursement: Number(contractorPayment?.reimbursement || '0'), + hourlyRate: Number(contractor?.hourlyRate || '0'), + contractorUuid: contractorUuid, + }, + { keepDirty: false, keepValues: false }, + ) + setAlerts({}) + setIsModalOpen(true) + onEvent(componentEvents.CONTRACTOR_HISTORICAL_PAYMENT_EDIT) + } + + const onEditContractorSubmit = (data: EditHistoricalPaymentFormValues) => { + const hasAnyPayment = + (data.wage ?? 0) > 0 || + (data.hours ?? 0) > 0 || + (data.bonus ?? 0) > 0 || + (data.reimbursement ?? 0) > 0 + + setVirtualContractorPayments(prevPayments => + prevPayments.map(payment => + payment.contractorUuid === data.contractorUuid + ? { + contractorUuid: payment.contractorUuid, + wage: String(data.wage ?? 0), + hours: String(data.hours ?? 0), + bonus: String(data.bonus ?? 0), + reimbursement: String(data.reimbursement ?? 0), + paymentMethod: HISTORICAL_PAYMENT_METHOD, + isTouched: hasAnyPayment, + } + : payment, + ), + ) + const displayContractor = contractors.find( + contractor => contractor.uuid === data.contractorUuid, + ) + const displayName = DOMPurify.sanitize( + displayContractor?.type === 'Individual' + ? firstLastName({ + first_name: displayContractor.firstName, + last_name: displayContractor.lastName, + }) + : displayContractor?.businessName || '', + ) + setAlerts(prevAlerts => ({ + ...prevAlerts, + [data.contractorUuid]: { + type: 'success', + title: t('alerts.contractorPaymentUpdated', { + contractorName: displayName, + interpolation: { escapeValue: false }, + }), + onDismiss: () => { + setAlerts(prevAlerts => { + const { [data.contractorUuid]: _, ...rest } = prevAlerts + return rest + }) + }, + }, + })) + setIsModalOpen(false) + onEvent(componentEvents.CONTRACTOR_HISTORICAL_PAYMENT_UPDATE, data) + } + + const onContinueToPreview = async () => { + await baseSubmitHandler(null, async () => { + const today = new Date().toISOString().split('T')[0] + if (paymentDate >= today!) { + setAlerts({ + ...alerts, + error: { + type: 'error', + title: t('alerts.dateMustBeInPast'), + }, + }) + return + } + + const contractorPayments = virtualContractorPayments.filter(payment => payment.isTouched) + if (contractorPayments.length === 0) { + setAlerts({ + ...alerts, + error: { + type: 'error', + title: t('alerts.noContractorPayments'), + }, + }) + return + } + setAlerts({}) + + const response = await previewContractorPaymentGroup({ + request: { + companyId, + requestBody: { + contractorPayments: contractorPayments.map(({ isTouched, ...rest }) => rest), + checkDate: new RFCDate(paymentDate), + }, + }, + }) + setPreviewData(response.contractorPaymentGroupPreview || null) + onEvent( + componentEvents.CONTRACTOR_HISTORICAL_PAYMENT_PREVIEW, + response.contractorPaymentGroupPreview, + ) + }) + } + + const onBackToEdit = () => { + setPreviewData(null) + onEvent(componentEvents.CONTRACTOR_HISTORICAL_PAYMENT_BACK_TO_EDIT) + } + + return ( + <> + {previewData && ( + + )} + {!previewData && ( + + )} + { + setIsModalOpen(false) + }} + formMethods={formMethods} + onSubmit={onEditContractorSubmit} + /> + + ) +} diff --git a/src/components/Contractor/Payments/HistoricalPayments/CreateHistoricalPayment/CreateHistoricalPaymentPresentation.tsx b/src/components/Contractor/Payments/HistoricalPayments/CreateHistoricalPayment/CreateHistoricalPaymentPresentation.tsx new file mode 100644 index 000000000..5a5a2d36b --- /dev/null +++ b/src/components/Contractor/Payments/HistoricalPayments/CreateHistoricalPayment/CreateHistoricalPaymentPresentation.tsx @@ -0,0 +1,200 @@ +import { useTranslation } from 'react-i18next' +import type { Contractor } from '@gusto/embedded-api/models/components/contractor' +import type { ContractorPayments } from '@gusto/embedded-api/models/operations/postv1companiescompanyidcontractorpaymentgroups' +import { useMemo } from 'react' +import type { InternalAlert } from '../types' +import { getContractorDisplayName, getMaxHistoricalDate, getMinHistoricalDate } from './helpers' +import { DataView, Flex, FlexItem, EmptyData } from '@/components/Common' +import { useComponentContext } from '@/contexts/ComponentAdapter/useComponentContext' +import { HamburgerMenu } from '@/components/Common/HamburgerMenu' +import { useI18n } from '@/i18n' +import { formatHoursDisplay } from '@/components/Payroll/helpers' +import useNumberFormatter from '@/hooks/useNumberFormatter' + +const ZERO_HOURS_DISPLAY = '0.000' + +interface CreateHistoricalPaymentPresentationProps { + contractors: Contractor[] + contractorPayments: (ContractorPayments & { isTouched?: boolean })[] + paymentDate: string + onPaymentDateChange: (date: string) => void + onSaveAndContinue: () => void + onEditContractor: (contractorUuid: string) => void + totals: { + wage: number + bonus: number + reimbursement: number + total: number + } + alerts: Record + isLoading: boolean +} + +export const CreateHistoricalPaymentPresentation = ({ + contractors, + paymentDate, + contractorPayments, + onPaymentDateChange, + onSaveAndContinue, + onEditContractor, + totals, + alerts, + isLoading, +}: CreateHistoricalPaymentPresentationProps) => { + const { Button, Text, Heading, TextInput, Alert } = useComponentContext() + useI18n('Contractor.Payments.HistoricalPayments.CreateHistoricalPayment') + const { t } = useTranslation('Contractor.Payments.HistoricalPayments.CreateHistoricalPayment') + const currencyFormatter = useNumberFormatter('currency') + + const formatWageType = (contractor?: Contractor) => { + if (!contractor) return '' + if (contractor.wageType === 'Hourly' && contractor.hourlyRate) { + return `${t('wageTypes.hourly')} ${currencyFormatter(Number(contractor.hourlyRate || '0'))}${t('perHour')}` + } + return contractor.wageType + } + + const tableData = useMemo( + () => + contractorPayments.map(payment => { + return { + ...payment, + contractorDetails: contractors.find( + contractor => contractor.uuid === payment.contractorUuid, + ), + } + }), + [contractorPayments, contractors], + ) + + return ( + + + + {t('title')} + {t('description')} + + + + + + + {Object.values(alerts).map(alert => ( + + {alert.content ?? null} + + ))} + + + + {t('dateHint')} + + + + {t('hoursAndPaymentsLabel')} + ( + {getContractorDisplayName(paymentData.contractorDetails)} + ), + }, + { + title: t('contractorTableHeaders.wageType'), + render: paymentData => {formatWageType(paymentData.contractorDetails)}, + }, + { + title: t('contractorTableHeaders.hours'), + render: paymentData => { + const hours = Number(paymentData.hours || '0') + return ( + + {paymentData.contractorDetails?.wageType === 'Hourly' && hours + ? formatHoursDisplay(hours) + : ZERO_HOURS_DISPLAY} + + ) + }, + }, + { + title: t('contractorTableHeaders.wage'), + render: paymentData => { + const amount = + paymentData.contractorDetails?.wageType === 'Fixed' && paymentData.wage + ? Number(paymentData.wage || '0') + : 0 + return {currencyFormatter(amount)} + }, + }, + { + title: t('contractorTableHeaders.bonus'), + render: paymentData => ( + {currencyFormatter(Number(paymentData.bonus || '0'))} + ), + }, + { + title: t('contractorTableHeaders.reimbursement'), + render: paymentData => ( + {currencyFormatter(Number(paymentData.reimbursement || '0'))} + ), + }, + { + title: t('contractorTableHeaders.total'), + render: ({ bonus, reimbursement, wage, hours, contractorDetails }) => { + const totalAmount = + Number(bonus || '0') + + Number(reimbursement || '0') + + Number(wage || '0') + + (contractorDetails?.wageType === 'Hourly' && hours + ? Number(hours || '0') * Number(contractorDetails.hourlyRate || '0') + : 0) + return {currencyFormatter(totalAmount)} + }, + }, + ]} + data={tableData} + footer={() => ({ + 'column-0': {t('totalsLabel')}, + 'column-3': {currencyFormatter(totals.wage)}, + 'column-4': {currencyFormatter(totals.bonus)}, + 'column-5': {currencyFormatter(totals.reimbursement)}, + 'column-6': {currencyFormatter(totals.total)}, + })} + label={t('hoursAndPaymentsLabel')} + itemMenu={paymentData => ( + { + onEditContractor(paymentData.contractorUuid!) + }, + }, + ]} + triggerLabel={t('editContractor')} + /> + )} + emptyState={() => ( + + )} + /> + + + ) +} diff --git a/src/components/Contractor/Payments/HistoricalPayments/CreateHistoricalPayment/EditHistoricalPaymentFormSchema.ts b/src/components/Contractor/Payments/HistoricalPayments/CreateHistoricalPayment/EditHistoricalPaymentFormSchema.ts new file mode 100644 index 000000000..7aaedf231 --- /dev/null +++ b/src/components/Contractor/Payments/HistoricalPayments/CreateHistoricalPayment/EditHistoricalPaymentFormSchema.ts @@ -0,0 +1,17 @@ +import { z } from 'zod' + +export const EditHistoricalPaymentFormSchema = z.object({ + wageType: z.enum(['Hourly', 'Fixed']), + hours: z.number().nonnegative().max(20000).optional(), + wage: z.number().nonnegative().optional(), + bonus: z.number().nonnegative().optional(), + reimbursement: z.number().nonnegative().optional(), + hourlyRate: z.number().nonnegative().optional(), + contractorUuid: z.string(), +}) + +export const createEditHistoricalPaymentFormSchema = () => { + return EditHistoricalPaymentFormSchema +} + +export type EditHistoricalPaymentFormValues = z.infer diff --git a/src/components/Contractor/Payments/HistoricalPayments/CreateHistoricalPayment/EditHistoricalPaymentPresentation.tsx b/src/components/Contractor/Payments/HistoricalPayments/CreateHistoricalPayment/EditHistoricalPaymentPresentation.tsx new file mode 100644 index 000000000..7b24b87d6 --- /dev/null +++ b/src/components/Contractor/Payments/HistoricalPayments/CreateHistoricalPayment/EditHistoricalPaymentPresentation.tsx @@ -0,0 +1,144 @@ +import { useId } from 'react' +import { FormProvider, useWatch, type UseFormReturn } from 'react-hook-form' +import { useTranslation } from 'react-i18next' +import type { EditHistoricalPaymentFormValues } from './EditHistoricalPaymentFormSchema' +import { useComponentContext } from '@/contexts/ComponentAdapter/useComponentContext' +import { ActionsLayout, Flex, Grid, NumberInputField } from '@/components/Common' +import { Form } from '@/components/Common/Form' +import { useI18n } from '@/i18n' +import useNumberFormatter from '@/hooks/useNumberFormatter' + +interface EditHistoricalPaymentPresentationProps { + isOpen: boolean + onClose: () => void + formMethods: UseFormReturn + onSubmit: (data: EditHistoricalPaymentFormValues) => void +} + +export const EditHistoricalPaymentPresentation = ({ + isOpen, + onClose, + formMethods, + onSubmit, +}: EditHistoricalPaymentPresentationProps) => { + const formId = useId() + useI18n('Contractor.Payments.HistoricalPayments.CreateHistoricalPayment') + const { t } = useTranslation('Contractor.Payments.HistoricalPayments.CreateHistoricalPayment', { + keyPrefix: 'editContractorPayment', + }) + const { Modal, Button, Text, Heading } = useComponentContext() + const currencyFormatter = useNumberFormatter('currency') + + const wageType = useWatch({ + name: 'wageType', + control: formMethods.control, + }) + const hours = useWatch({ + name: 'hours', + control: formMethods.control, + }) + const wage = useWatch({ + name: 'wage', + control: formMethods.control, + }) + const bonus = useWatch({ + name: 'bonus', + control: formMethods.control, + }) + const reimbursement = useWatch({ + name: 'reimbursement', + control: formMethods.control, + }) + const hourlyRate = useWatch({ + name: 'hourlyRate', + control: formMethods.control, + }) + + const totalAmount = + (wageType === 'Fixed' ? 0 : bonus || 0) + + (reimbursement || 0) + + (wage || 0) + + (hours || 0) * (hourlyRate || 0) + + return ( + + + + + } + > + +
+ + + {t('title')} + {t('subtitle')} + + {t('totalPay')}: {currencyFormatter(totalAmount)} + + + + {wageType === 'Hourly' && ( + + {t('hoursSection')} + + + )} + + {wageType === 'Fixed' && ( + + {t('fixedPaySection')} + + + )} + + + {t('additionalEarningsSection')} + + {wageType === 'Hourly' && ( + + )} + + + + +
+
+
+ ) +} diff --git a/src/components/Contractor/Payments/HistoricalPayments/CreateHistoricalPayment/HistoricalPaymentSuccess.tsx b/src/components/Contractor/Payments/HistoricalPayments/CreateHistoricalPayment/HistoricalPaymentSuccess.tsx new file mode 100644 index 000000000..c025e0c3b --- /dev/null +++ b/src/components/Contractor/Payments/HistoricalPayments/CreateHistoricalPayment/HistoricalPaymentSuccess.tsx @@ -0,0 +1,45 @@ +import { useContractorPaymentGroupsGetSuspense } from '@gusto/embedded-api/react-query/contractorPaymentGroupsGet' +import { useContractorsListSuspense } from '@gusto/embedded-api/react-query/contractorsList' +import { HistoricalPaymentSuccessPresentation } from './HistoricalPaymentSuccessPresentation' +import { useI18n } from '@/i18n' +import { componentEvents, type EventType } from '@/shared/constants' + +interface HistoricalPaymentSuccessProps { + paymentGroupId: string + companyId: string + onEvent: (type: EventType, data?: unknown) => void +} + +export const HistoricalPaymentSuccess = ({ + paymentGroupId, + companyId, + onEvent, +}: HistoricalPaymentSuccessProps) => { + useI18n('Contractor.Payments.HistoricalPayments.CreateHistoricalPayment') + + const { data: paymentGroupData } = useContractorPaymentGroupsGetSuspense({ + contractorPaymentGroupUuid: paymentGroupId, + }) + const contractorPaymentGroup = paymentGroupData.contractorPaymentGroup + + const { data: contractorList } = useContractorsListSuspense({ companyUuid: companyId }) + const contractors = (contractorList.contractors || []).filter( + contractor => contractor.isActive && contractor.onboardingStatus === 'onboarding_completed', + ) + + if (!contractorPaymentGroup) { + return null + } + + const handleDone = () => { + onEvent(componentEvents.CONTRACTOR_HISTORICAL_PAYMENT_EXIT) + } + + return ( + + ) +} diff --git a/src/components/Contractor/Payments/HistoricalPayments/CreateHistoricalPayment/HistoricalPaymentSuccessPresentation.tsx b/src/components/Contractor/Payments/HistoricalPayments/CreateHistoricalPayment/HistoricalPaymentSuccessPresentation.tsx new file mode 100644 index 000000000..6173b4eb1 --- /dev/null +++ b/src/components/Contractor/Payments/HistoricalPayments/CreateHistoricalPayment/HistoricalPaymentSuccessPresentation.tsx @@ -0,0 +1,166 @@ +import { useTranslation } from 'react-i18next' +import type { ContractorPaymentGroup } from '@gusto/embedded-api/models/components/contractorpaymentgroup' +import type { ContractorPaymentForGroup } from '@gusto/embedded-api/models/components/contractorpaymentforgroup' +import { useMemo } from 'react' +import type { Contractor } from '@gusto/embedded-api/models/components/contractor' +import { getContractorDisplayName } from './helpers' +import { DataView, Flex } from '@/components/Common' +import { useComponentContext } from '@/contexts/ComponentAdapter/useComponentContext' +import { useI18n } from '@/i18n' +import { formatHoursDisplay } from '@/components/Payroll/helpers' +import useNumberFormatter from '@/hooks/useNumberFormatter' + +const ZERO_HOURS_DISPLAY = '0.000' + +interface HistoricalPaymentSuccessPresentationProps { + contractorPaymentGroup: ContractorPaymentGroup + contractors: Contractor[] + onDone: () => void +} + +export const HistoricalPaymentSuccessPresentation = ({ + contractorPaymentGroup, + contractors, + onDone, +}: HistoricalPaymentSuccessPresentationProps) => { + const { Button, Text, Heading, Alert } = useComponentContext() + useI18n('Contractor.Payments.HistoricalPayments.CreateHistoricalPayment') + const { t } = useTranslation('Contractor.Payments.HistoricalPayments.CreateHistoricalPayment', { + keyPrefix: 'successPresentation', + }) + const currencyFormatter = useNumberFormatter() + + const formatWageType = (contractor: ContractorPaymentForGroup) => { + if (contractor.wageType === 'Hourly' && contractor.hourlyRate) { + return `${t('wageTypes.hourly')} ${currencyFormatter(Number(contractor.hourlyRate || '0'))}${t('perHour')}` + } + return contractor.wageType + } + + const contractorPayments = contractorPaymentGroup.contractorPayments || [] + + const totals = useMemo( + () => + contractorPayments.reduce( + (acc, contractor) => { + acc.wageAmount += Number(contractor.wage || '0') + acc.bonusAmount += Number(contractor.bonus || '0') + acc.reimbursementAmount += Number(contractor.reimbursement || '0') + acc.totalAmount += Number(contractor.wageTotal || '0') + return acc + }, + { wageAmount: 0, bonusAmount: 0, reimbursementAmount: 0, totalAmount: 0 }, + ), + [contractorPayments], + ) + + return ( + + + + {t('successMessage', { + count: contractorPayments.length, + })} + + + + + + {t('summaryTitle')} + + {t('summarySubtitle', { checkDate: contractorPaymentGroup.checkDate })} + + + + + + ( + {currencyFormatter(Number(contractorPaymentGroup.totals?.amount || '0'))} + ), + }, + { + title: t('summaryTableHeaders.contractorPayDate'), + render: () => {contractorPaymentGroup.checkDate || ''}, + }, + ]} + data={[contractorPaymentGroup]} + label="Payment Summary" + /> + + {contractorPayments.length > 0 && ( + ( + + {getContractorDisplayName( + contractors.find( + contractor => contractor.uuid === contractorPayment.contractorUuid, + ), + )} + + ), + }, + { + title: t('contractorTableHeaders.wageType'), + render: contractorPayment => {formatWageType(contractorPayment)}, + }, + { + title: t('contractorTableHeaders.hours'), + render: contractorPayment => { + const hours = Number(contractorPayment.hours || '0') + return ( + + {contractorPayment.wageType === 'Hourly' && hours + ? formatHoursDisplay(hours) + : ZERO_HOURS_DISPLAY} + + ) + }, + }, + { + title: t('contractorTableHeaders.wage'), + render: contractorPayment => ( + {currencyFormatter(Number(contractorPayment.wage || '0'))} + ), + }, + { + title: t('contractorTableHeaders.bonus'), + render: contractorPayment => ( + {currencyFormatter(Number(contractorPayment.bonus || '0'))} + ), + }, + { + title: t('contractorTableHeaders.reimbursement'), + render: contractorPayment => ( + {currencyFormatter(Number(contractorPayment.reimbursement || '0'))} + ), + }, + { + title: t('contractorTableHeaders.total'), + render: contractorPayment => ( + {currencyFormatter(Number(contractorPayment.wageTotal || '0'))} + ), + }, + ]} + data={contractorPayments} + footer={() => ({ + 'column-0': {t('totalsLabel')}, + 'column-3': {currencyFormatter(totals.wageAmount || 0)}, + 'column-4': {currencyFormatter(totals.bonusAmount || 0)}, + 'column-5': {currencyFormatter(totals.reimbursementAmount || 0)}, + 'column-6': {currencyFormatter(totals.totalAmount || 0)}, + })} + label="Contractor Payments" + /> + )} + + ) +} diff --git a/src/components/Contractor/Payments/HistoricalPayments/CreateHistoricalPayment/HistoricalPreviewPresentation.tsx b/src/components/Contractor/Payments/HistoricalPayments/CreateHistoricalPayment/HistoricalPreviewPresentation.tsx new file mode 100644 index 000000000..90efeb337 --- /dev/null +++ b/src/components/Contractor/Payments/HistoricalPayments/CreateHistoricalPayment/HistoricalPreviewPresentation.tsx @@ -0,0 +1,170 @@ +import { useTranslation } from 'react-i18next' +import type { ContractorPaymentGroupPreview } from '@gusto/embedded-api/models/components/contractorpaymentgrouppreview' +import type { ContractorPaymentForGroupPreview } from '@gusto/embedded-api/models/components/contractorpaymentforgrouppreview' +import { useMemo } from 'react' +import type { Contractor } from '@gusto/embedded-api/models/components/contractor' +import { getContractorDisplayName } from './helpers' +import { DataView, Flex } from '@/components/Common' +import { useComponentContext } from '@/contexts/ComponentAdapter/useComponentContext' +import { useI18n } from '@/i18n' +import { formatHoursDisplay } from '@/components/Payroll/helpers' +import useNumberFormatter from '@/hooks/useNumberFormatter' + +const ZERO_HOURS_DISPLAY = '0.000' + +interface HistoricalPreviewPresentationProps { + contractorPaymentGroup: ContractorPaymentGroupPreview + contractors: Contractor[] + onBackToEdit: () => void + onSubmit: () => void + isLoading: boolean +} + +export const HistoricalPreviewPresentation = ({ + contractorPaymentGroup, + contractors, + onBackToEdit, + onSubmit, + isLoading, +}: HistoricalPreviewPresentationProps) => { + const { Button, Text, Heading } = useComponentContext() + useI18n('Contractor.Payments.HistoricalPayments.CreateHistoricalPayment') + const { t } = useTranslation('Contractor.Payments.HistoricalPayments.CreateHistoricalPayment', { + keyPrefix: 'previewPresentation', + }) + const currencyFormatter = useNumberFormatter() + + const formatWageType = (contractor: ContractorPaymentForGroupPreview) => { + if (contractor.wageType === 'Hourly' && contractor.hourlyRate) { + return `${t('wageTypes.hourly')} ${currencyFormatter(Number(contractor.hourlyRate || '0'))}${t('perHour')}` + } + return contractor.wageType + } + + const totals = useMemo( + () => + contractorPaymentGroup.contractorPayments?.reduce( + (acc, contractor) => { + acc.wageAmount += Number(contractor.wage || '0') + acc.bonusAmount += Number(contractor.bonus || '0') + acc.reimbursementAmount += Number(contractor.reimbursement || '0') + acc.totalAmount += Number(contractor.wageTotal || '0') + return acc + }, + { wageAmount: 0, bonusAmount: 0, reimbursementAmount: 0, totalAmount: 0 }, + ), + [contractorPaymentGroup.contractorPayments], + ) + + const contractorPayments = contractorPaymentGroup.contractorPayments || [] + + return ( + + + + {t('reviewAndSubmitTitle')} + + {t('reviewSubtitle', { checkDate: contractorPaymentGroup.checkDate })} + + + + + + + + + {t('paymentSummaryTitle')} + ( + {currencyFormatter(Number(contractorPaymentGroup.totals?.amount || '0'))} + ), + }, + { + title: t('summaryTableHeaders.contractorPayDate'), + render: () => {contractorPaymentGroup.checkDate || ''}, + }, + ]} + data={[contractorPaymentGroup]} + label="Payment Summary" + /> + + ( + + {getContractorDisplayName( + contractors.find( + contractor => contractor.uuid === contractorPayment.contractorUuid, + ), + )} + + ), + }, + { + title: t('contractorTableHeaders.wageType'), + render: contractorPayment => {formatWageType(contractorPayment)}, + }, + { + title: t('contractorTableHeaders.hours'), + render: contractorPayment => { + const hours = Number(contractorPayment.hours || '0') + return ( + + {contractorPayment.wageType === 'Hourly' && hours + ? formatHoursDisplay(hours) + : ZERO_HOURS_DISPLAY} + + ) + }, + }, + { + title: t('contractorTableHeaders.wage'), + render: contractorPayment => ( + + {contractorPayment.wageType === 'Fixed' && contractorPayment.wage + ? currencyFormatter(Number(contractorPayment.wage || '0')) + : currencyFormatter(0)} + + ), + }, + { + title: t('contractorTableHeaders.bonus'), + render: contractorPayment => ( + {currencyFormatter(Number(contractorPayment.bonus || '0'))} + ), + }, + { + title: t('contractorTableHeaders.reimbursement'), + render: contractorPayment => ( + {currencyFormatter(Number(contractorPayment.reimbursement || '0'))} + ), + }, + { + title: t('contractorTableHeaders.total'), + render: contractorPayment => ( + {currencyFormatter(Number(contractorPayment.wageTotal || '0'))} + ), + }, + ]} + data={contractorPayments} + footer={() => ({ + 'column-0': {t('totalsLabel')}, + 'column-3': {currencyFormatter(totals?.wageAmount ?? 0)}, + 'column-4': {currencyFormatter(totals?.bonusAmount ?? 0)}, + 'column-5': {currencyFormatter(totals?.reimbursementAmount ?? 0)}, + 'column-6': {currencyFormatter(totals?.totalAmount ?? 0)}, + })} + label="Contractor Payments" + /> + + ) +} diff --git a/src/components/Contractor/Payments/HistoricalPayments/CreateHistoricalPayment/helpers.ts b/src/components/Contractor/Payments/HistoricalPayments/CreateHistoricalPayment/helpers.ts new file mode 100644 index 000000000..9074c41ec --- /dev/null +++ b/src/components/Contractor/Payments/HistoricalPayments/CreateHistoricalPayment/helpers.ts @@ -0,0 +1,30 @@ +import type { Contractor } from '@gusto/embedded-api/models/components/contractor' +import { firstLastName } from '@/helpers/formattedStrings' + +export const getContractorDisplayName = (contractor?: Contractor): string => { + if (!contractor) { + return '' + } + if (contractor.type === 'Individual') { + return firstLastName({ first_name: contractor.firstName, last_name: contractor.lastName }) + } else { + return contractor.businessName || '' + } +} + +export const calculateDefaultHistoricalDate = (): string => { + const yesterday = new Date() + yesterday.setDate(yesterday.getDate() - 1) + return yesterday.toISOString().split('T')[0] || '' +} + +export const getMaxHistoricalDate = (): string => { + const today = new Date() + return today.toISOString().split('T')[0] || '' +} + +export const getMinHistoricalDate = (): string => { + const twoYearsAgo = new Date() + twoYearsAgo.setFullYear(twoYearsAgo.getFullYear() - 2) + return twoYearsAgo.toISOString().split('T')[0] || '' +} diff --git a/src/components/Contractor/Payments/HistoricalPayments/HistoricalPaymentFlow/HistoricalPaymentFlow.tsx b/src/components/Contractor/Payments/HistoricalPayments/HistoricalPaymentFlow/HistoricalPaymentFlow.tsx new file mode 100644 index 000000000..c5ae2de18 --- /dev/null +++ b/src/components/Contractor/Payments/HistoricalPayments/HistoricalPaymentFlow/HistoricalPaymentFlow.tsx @@ -0,0 +1,33 @@ +import { createMachine } from 'robot3' +import { useMemo } from 'react' +import { + historicalPaymentFlowBreadcrumbsNodes, + historicalPaymentMachine, +} from './historicalPaymentStateMachine' +import { + CreateHistoricalPaymentContextual, + type HistoricalPaymentFlowContextInterface, + type HistoricalPaymentFlowProps, +} from './HistoricalPaymentFlowComponents' +import { Flow } from '@/components/Flow/Flow' +import { buildBreadcrumbs } from '@/helpers/breadcrumbHelpers' + +export const HistoricalPaymentFlow = ({ companyId, onEvent }: HistoricalPaymentFlowProps) => { + const historicalPaymentFlow = useMemo( + () => + createMachine( + 'createHistoricalPayment', + historicalPaymentMachine, + (initialContext: HistoricalPaymentFlowContextInterface) => ({ + ...initialContext, + component: CreateHistoricalPaymentContextual, + companyId, + progressBarType: null, + breadcrumbs: buildBreadcrumbs(historicalPaymentFlowBreadcrumbsNodes), + currentBreadcrumb: 'createHistoricalPayment', + }), + ), + [companyId], + ) + return +} diff --git a/src/components/Contractor/Payments/HistoricalPayments/HistoricalPaymentFlow/HistoricalPaymentFlowComponents.tsx b/src/components/Contractor/Payments/HistoricalPayments/HistoricalPaymentFlow/HistoricalPaymentFlowComponents.tsx new file mode 100644 index 000000000..8d0108ade --- /dev/null +++ b/src/components/Contractor/Payments/HistoricalPayments/HistoricalPaymentFlow/HistoricalPaymentFlowComponents.tsx @@ -0,0 +1,35 @@ +import { CreateHistoricalPayment } from '../CreateHistoricalPayment/CreateHistoricalPayment' +import { HistoricalPaymentSuccess } from '../CreateHistoricalPayment/HistoricalPaymentSuccess' +import type { InternalAlert } from '../types' +import { useFlow, type FlowContextInterface } from '@/components/Flow/useFlow' +import type { BaseComponentInterface } from '@/components/Base' +import type { BreadcrumbTrail } from '@/components/Common/FlowBreadcrumbs/FlowBreadcrumbsTypes' +import { ensureRequired } from '@/helpers/ensureRequired' + +export interface HistoricalPaymentFlowProps extends BaseComponentInterface { + companyId: string +} + +export interface HistoricalPaymentFlowContextInterface extends FlowContextInterface { + companyId: string + breadcrumbs?: BreadcrumbTrail + createdPaymentGroupId?: string + alerts?: InternalAlert[] +} + +export function CreateHistoricalPaymentContextual() { + const { companyId, onEvent } = useFlow() + return +} + +export function HistoricalPaymentSuccessContextual() { + const { companyId, createdPaymentGroupId, onEvent } = + useFlow() + return ( + + ) +} diff --git a/src/components/Contractor/Payments/HistoricalPayments/HistoricalPaymentFlow/historicalPaymentStateMachine.ts b/src/components/Contractor/Payments/HistoricalPayments/HistoricalPaymentFlow/historicalPaymentStateMachine.ts new file mode 100644 index 000000000..38729f110 --- /dev/null +++ b/src/components/Contractor/Payments/HistoricalPayments/HistoricalPaymentFlow/historicalPaymentStateMachine.ts @@ -0,0 +1,91 @@ +import { reduce, state, transition } from 'robot3' +import type { ContractorPaymentGroup } from '@gusto/embedded-api/models/components/contractorpaymentgroup' +import { + HistoricalPaymentSuccessContextual, + type HistoricalPaymentFlowContextInterface, +} from './HistoricalPaymentFlowComponents' +import { componentEvents } from '@/shared/constants' +import type { MachineEventType, MachineTransition } from '@/types/Helpers' +import type { BreadcrumbNodes } from '@/components/Common/FlowBreadcrumbs/FlowBreadcrumbsTypes' + +type EventPayloads = { + [componentEvents.CONTRACTOR_HISTORICAL_PAYMENT_CREATE]: undefined + [componentEvents.CONTRACTOR_HISTORICAL_PAYMENT_CREATED]: ContractorPaymentGroup + [componentEvents.CONTRACTOR_HISTORICAL_PAYMENT_EXIT]: { uuid?: string | null } +} + +export const historicalPaymentFlowBreadcrumbsNodes: BreadcrumbNodes = { + createHistoricalPayment: { + parent: null, + item: { + id: 'createHistoricalPayment', + label: 'breadcrumbLabel', + namespace: 'Contractor.Payments.HistoricalPayments.CreateHistoricalPayment', + }, + }, +} as const + +export const historicalPaymentMachine = { + createHistoricalPayment: state( + transition( + componentEvents.CONTRACTOR_HISTORICAL_PAYMENT_CREATED, + 'success', + reduce( + ( + ctx: HistoricalPaymentFlowContextInterface, + ev: MachineEventType< + EventPayloads, + typeof componentEvents.CONTRACTOR_HISTORICAL_PAYMENT_CREATED + >, + ): HistoricalPaymentFlowContextInterface => { + return { + ...ctx, + component: HistoricalPaymentSuccessContextual, + createdPaymentGroupId: ev.payload.uuid, + } + }, + ), + ), + transition( + componentEvents.CONTRACTOR_HISTORICAL_PAYMENT_EXIT, + 'done', + reduce( + ( + ctx: HistoricalPaymentFlowContextInterface, + ev: MachineEventType< + EventPayloads, + typeof componentEvents.CONTRACTOR_HISTORICAL_PAYMENT_EXIT + >, + ): HistoricalPaymentFlowContextInterface => { + return { + ...ctx, + component: null, + alerts: undefined, + } + }, + ), + ), + ), + success: state( + transition( + componentEvents.CONTRACTOR_HISTORICAL_PAYMENT_EXIT, + 'done', + reduce( + ( + ctx: HistoricalPaymentFlowContextInterface, + ev: MachineEventType< + EventPayloads, + typeof componentEvents.CONTRACTOR_HISTORICAL_PAYMENT_EXIT + >, + ): HistoricalPaymentFlowContextInterface => { + return { + ...ctx, + component: null, + alerts: undefined, + } + }, + ), + ), + ), + done: state(), +} diff --git a/src/components/Contractor/Payments/HistoricalPayments/HistoricalPaymentFlow/index.ts b/src/components/Contractor/Payments/HistoricalPayments/HistoricalPaymentFlow/index.ts new file mode 100644 index 000000000..7ebdc50cd --- /dev/null +++ b/src/components/Contractor/Payments/HistoricalPayments/HistoricalPaymentFlow/index.ts @@ -0,0 +1,2 @@ +export { HistoricalPaymentFlow } from './HistoricalPaymentFlow' +export type { HistoricalPaymentFlowProps } from './HistoricalPaymentFlowComponents' diff --git a/src/components/Contractor/Payments/HistoricalPayments/README.md b/src/components/Contractor/Payments/HistoricalPayments/README.md new file mode 100644 index 000000000..e645ae9e6 --- /dev/null +++ b/src/components/Contractor/Payments/HistoricalPayments/README.md @@ -0,0 +1,103 @@ +# Historical Contractor Payments SDK — AI Pilot Experiment + +> **Status:** Draft / Experiment — not intended for production merge as-is. +> This is the output of an AI-assisted SDK component generation pilot. We're sharing it for eng commentary on pattern adherence, architectural decisions, and feasibility of this approach. + +## Background + +Historical Contractor Payments was explicitly descoped from the Contractor Payments SDK launch ([Launch Checklist](https://docs.google.com/document/d/1__Fr0XhVNLrg2cPTG8RkU0becFRz8c2lfduzx4jLbmI/edit)) because mixing historical and future-dated payments in a single flow caused UX confusion — date constraints, payment speed, and ACH validation that apply to future payments don't apply to historical ones. + +This experiment builds a standalone `HistoricalPaymentFlow` component that provides a dedicated, simplified flow for recording past-dated contractor payments, using the same API endpoints (`contractor_payment_groups`) with `payment_method: "Historical Payment"`. + +## What This Component Does + +A 3-step journey: + +1. **Select a past payment date** — Date picker constrained to past dates, defaulting to yesterday. No payment speed or ACH configuration. +2. **Select contractors and specify amounts** — Table of active, onboarded contractors. Edit modal for hours (hourly), wage (fixed), bonus, and reimbursement. Payment method is always "Historical Payment" (no selector). +3. **Review and submit** — Preview via `contractor_payment_groups/preview` API, then submit using the `creationToken` for idempotency. Success confirmation screen with payment summary. + +## How It Was Built + +This was generated through an AI-assisted workflow (Claude Code acting as CTO advisor): + +1. **Pattern analysis** — Read the existing Contractor Payments SDK components (`CreatePayment`, `PaymentFlow`, `PaymentSummary`, `EditContractorPaymentPresentation`, etc.) to extract every architectural pattern: robot3 state machines, container/presentation split, BaseComponent wrapper, ComponentsContext, Zod form schemas with factory functions, i18n namespacing, event system, DataView tables, DOMPurify sanitization. + +2. **Mini-PRD** — Created acceptance criteria (32 checkboxes) covering both structural requirements (must match existing patterns) and functional requirements (historical-specific behavior). See `PRDs/PRD-Historical-Contractor-Payments-SDK.md` in the workspace root. + +3. **Implementation** — Generated all components following the extracted patterns, making intentional simplifications where the historical domain differs: + - No payment method selector (always "Historical Payment") + - No submission blocker UI (not applicable to historical payments) + - No wire/bank account integration + - No debit date/amount in summary tables + - Simplified state machine (3 states vs 6+ in PaymentFlow) + +4. **Verification** — TypeScript compilation, ESLint, Prettier all pass. Tested the full flow end-to-end in the SDK Dev App with live demo data via gws-flows. + +## Files Created + +``` +src/components/Contractor/Payments/HistoricalPayments/ +├── types.ts # Shared types (reuses InternalAlert from existing) +├── README.md # This file +├── CreateHistoricalPayment/ +│ ├── CreateHistoricalPayment.tsx # Container — data fetching, form setup, submit handlers +│ ├── CreateHistoricalPaymentPresentation.tsx # Date picker + contractor table view +│ ├── EditHistoricalPaymentPresentation.tsx # Modal for editing individual contractor pay +│ ├── EditHistoricalPaymentFormSchema.ts # Zod schema (no paymentMethod field) +│ ├── HistoricalPreviewPresentation.tsx # Review & submit view +│ ├── HistoricalPaymentSuccess.tsx # Success container (fetches payment group) +│ ├── HistoricalPaymentSuccessPresentation.tsx # Success confirmation view +│ └── helpers.ts # Date helpers, contractor display name +└── HistoricalPaymentFlow/ + ├── HistoricalPaymentFlow.tsx # Entry point — creates robot3 machine + ├── HistoricalPaymentFlowComponents.tsx # Contextual wrappers (useFlow + ensureRequired) + ├── historicalPaymentStateMachine.ts # 3-state machine: create → success → done + └── index.ts # Public exports +``` + +**Modified existing files:** + +- `src/shared/constants.ts` — Added 7 `CONTRACTOR_HISTORICAL_PAYMENT_*` event constants +- `src/components/Contractor/index.ts` — Added exports for `HistoricalPaymentFlow` and `CreateHistoricalPayment` +- `src/i18n/en/` — Two new translation JSON files +- `src/types/i18next.d.ts` — Regenerated (auto-generated file) + +## Current Status + +### What works + +- Full flow renders and functions in the SDK Dev App +- Live API calls succeed (preview + create) against gws-flows demo environment +- Date validation (past-only) enforced client-side and by API +- Contractor filtering, hourly/fixed wage handling, totals calculations all verified +- Success confirmation screen with payment summary after submission +- All events fire correctly (visible in SDK Dev App event log) +- TypeScript, ESLint, and Prettier all pass + +### What's missing before this could be production-ready + +- **Unit tests** — No Vitest test files. The existing `CreatePayment` has test coverage that should be mirrored. +- **Storybook stories** — Team convention is Storybook-first development. No stories exist for these components. +- **Accessibility audit** — Keyboard navigation, screen reader behavior, and ARIA attributes haven't been reviewed. +- **Design review** — Built from existing patterns + Figma references, but no dedicated design review of this specific flow. +- **Edge cases** — Behavior with 0 contractors, very long contractor lists, network errors mid-flow, etc. +- **Integration into PaymentFlow** — Currently standalone only. If we want it reachable from the existing PaymentFlow landing page, that's additional state machine wiring. + +## How to Test + +1. Check out this branch +2. `npm install` +3. `npm run sdk-app` +4. In the SDK Dev App, provision a demo company (or use an existing one) +5. Find **"Contractor.Payments.HistoricalPayments.HistoricalPaymentFlow"** in the sidebar +6. Walk through the flow: pick a past date → edit contractor amounts → continue to preview → submit + +## What We're Looking For + +This is shared for commentary, not merge approval. We'd appreciate feedback on: + +- **Pattern adherence** — Does the code follow SDK conventions correctly? Anything that would need to change? +- **Architectural decisions** — Is the simplified 3-state machine the right call, or should preview be a separate state? Should the success screen be a standalone component (like PaymentSummary) rather than embedded in the flow? +- **Gaps** — What edge cases or requirements did the AI miss? +- **AI pilot viability** — Given this output, is AI-assisted component generation a viable accelerator for future SDK work? What would make it more useful? diff --git a/src/components/Contractor/Payments/HistoricalPayments/types.ts b/src/components/Contractor/Payments/HistoricalPayments/types.ts new file mode 100644 index 000000000..bfa895eac --- /dev/null +++ b/src/components/Contractor/Payments/HistoricalPayments/types.ts @@ -0,0 +1,16 @@ +import type { ContractorPaymentGroup } from '@gusto/embedded-api/models/components/contractorpaymentgroup' +import type { ContractorPaymentForGroup } from '@gusto/embedded-api/models/components/contractorpaymentforgroup' +import type { ContractorPaymentGroupTotals } from '@gusto/embedded-api/models/components/contractorpaymentgroup' +import type { ReactNode } from 'react' + +export type { ContractorPaymentGroup, ContractorPaymentForGroup, ContractorPaymentGroupTotals } + +export type InternalAlert = { + type: 'error' | 'info' | 'success' + title: string + content?: ReactNode + onDismiss?: () => void + translationParams?: Record + onAction?: () => void + actionLabel?: string +} diff --git a/src/components/Contractor/index.ts b/src/components/Contractor/index.ts index c1cc1d64f..bb3ef8226 100644 --- a/src/components/Contractor/index.ts +++ b/src/components/Contractor/index.ts @@ -11,3 +11,5 @@ export { CreatePayment } from './Payments/CreatePayment/CreatePayment' export { PaymentHistory } from './Payments/PaymentHistory/PaymentHistory' export { PaymentSummary } from './Payments/PaymentSummary' export { PaymentStatement } from './Payments/PaymentStatement/PaymentStatement' +export { HistoricalPaymentFlow } from './Payments/HistoricalPayments/HistoricalPaymentFlow' +export { CreateHistoricalPayment } from './Payments/HistoricalPayments/CreateHistoricalPayment/CreateHistoricalPayment' diff --git a/src/i18n/en/Contractor.Payments.HistoricalPayments.CreateHistoricalPayment.json b/src/i18n/en/Contractor.Payments.HistoricalPayments.CreateHistoricalPayment.json new file mode 100644 index 000000000..e63d3dee0 --- /dev/null +++ b/src/i18n/en/Contractor.Payments.HistoricalPayments.CreateHistoricalPayment.json @@ -0,0 +1,102 @@ +{ + "title": "Record historical contractor payment", + "description": "Record a past contractor payment that has already taken place outside the system.", + "emptyTableTitle": "No contractors available for payment", + "emptyTableDescription": "There are no active contractors with completed onboarding. Add and onboard contractors before creating payments.", + "breadcrumbLabel": "Record historical payment", + "dateLabel": "Payment date", + "dateHint": "Select the date the payment was originally made. Must be a past date.", + "hoursAndPaymentsLabel": "Hours and payments", + "contractorTableHeaders": { + "contractor": "Contractor", + "wageType": "Wage type", + "hours": "Hours", + "wage": "Wage", + "bonus": "Bonus", + "reimbursement": "Reimbursement", + "total": "Total" + }, + "na": "N/A", + "totalsLabel": "Totals", + "backButton": "Back", + "continueCta": "Continue", + "editContractor": "Edit contractor payment", + "perHour": "/hr", + "wageTypes": { + "fixed": "Fixed", + "hourly": "Hourly" + }, + "alerts": { + "contractorPaymentUpdated": "Pay updated for {{contractorName}}", + "noContractorPayments": "Please add at least one contractor payment to continue.", + "dateMustBeInPast": "Payment date must be in the past for historical payments." + }, + "editContractorPayment": { + "title": "Edit contractor pay", + "subtitle": "Edit contractor's hours, additional earnings, and reimbursements. Inputs not applicable to this contractor are disabled. Please click \"Done\" to apply the change.", + "hoursSection": "Hours", + "hoursLabel": "Hours", + "hoursAdornment": "hrs", + "fixedPaySection": "Fixed pay", + "wageLabel": "Wage", + "additionalEarningsSection": "Additional earnings", + "bonusLabel": "Bonus", + "reimbursementLabel": "Reimbursement", + "totalPay": "Total pay", + "cancelCta": "Cancel", + "saveCta": "Done" + }, + "previewPresentation": { + "breadcrumbLabel": "Submit", + "reviewAndSubmitTitle": "Review and submit", + "reviewSubtitle": "Recording historical contractor payment for {{checkDate}}", + "summaryTableHeaders": { + "totalAmount": "Total amount", + "contractorPayDate": "Payment date" + }, + "paymentSummaryTitle": "Payment summary", + "contractorTableHeaders": { + "contractor": "Contractor", + "wageType": "Wage type", + "hours": "Hours", + "wage": "Wage", + "bonus": "Bonus", + "reimbursement": "Reimbursement", + "total": "Total" + }, + "totalsLabel": "Totals", + "editButton": "Edit", + "submitButton": "Submit", + "perHour": "/hr", + "wageTypes": { + "fixed": "Fixed", + "hourly": "Hourly" + } + }, + "successPresentation": { + "successTitle": "Historical payment recorded", + "successMessage": "Payment has been recorded for {{count}} contractor(s).", + "summaryTitle": "Payment summary", + "summarySubtitle": "Historical payment recorded for {{checkDate}}", + "doneCta": "Done", + "summaryTableHeaders": { + "totalAmount": "Total amount", + "contractorPayDate": "Payment date" + }, + "contractorTableHeaders": { + "contractor": "Contractor", + "wageType": "Wage type", + "hours": "Hours", + "wage": "Wage", + "bonus": "Bonus", + "reimbursement": "Reimbursement", + "total": "Total" + }, + "totalsLabel": "Totals", + "perHour": "/hr", + "wageTypes": { + "fixed": "Fixed", + "hourly": "Hourly" + } + } +} diff --git a/src/i18n/en/Contractor.Payments.HistoricalPayments.HistoricalPaymentFlow.json b/src/i18n/en/Contractor.Payments.HistoricalPayments.HistoricalPaymentFlow.json new file mode 100644 index 000000000..7b2a91827 --- /dev/null +++ b/src/i18n/en/Contractor.Payments.HistoricalPayments.HistoricalPaymentFlow.json @@ -0,0 +1,3 @@ +{ + "breadcrumbLabel": "Historical payments" +} diff --git a/src/shared/constants.ts b/src/shared/constants.ts index c976de22a..b9be526de 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -131,6 +131,16 @@ export const contractorPaymentEvents = { CONTRACTOR_PAYMENT_RFI_RESPOND: 'contractor/payments/rfi/respond', } as const +export const contractorHistoricalPaymentEvents = { + CONTRACTOR_HISTORICAL_PAYMENT_CREATE: 'contractor/historicalPayments/create', + CONTRACTOR_HISTORICAL_PAYMENT_EDIT: 'contractor/historicalPayments/edit', + CONTRACTOR_HISTORICAL_PAYMENT_UPDATE: 'contractor/historicalPayments/update', + CONTRACTOR_HISTORICAL_PAYMENT_PREVIEW: 'contractor/historicalPayments/preview', + CONTRACTOR_HISTORICAL_PAYMENT_BACK_TO_EDIT: 'contractor/historicalPayments/backToEdit', + CONTRACTOR_HISTORICAL_PAYMENT_CREATED: 'contractor/historicalPayments/created', + CONTRACTOR_HISTORICAL_PAYMENT_EXIT: 'contractor/historicalPayments/exit', +} as const + export const terminationEvents = { EMPLOYEE_TERMINATION_CREATED: 'employee/termination/created', EMPLOYEE_TERMINATION_UPDATED: 'employee/termination/updated', @@ -254,6 +264,7 @@ export const componentEvents = { ...informationRequestEvents, ...recoveryCasesEvents, ...contractorPaymentEvents, + ...contractorHistoricalPaymentEvents, ...offCycleEvents, ...terminationEvents, ...timeOffEvents, diff --git a/src/types/i18next.d.ts b/src/types/i18next.d.ts index 36ff4c379..96bc76475 100644 --- a/src/types/i18next.d.ts +++ b/src/types/i18next.d.ts @@ -975,6 +975,111 @@ export interface ContractorPaymentsCreatePayment{ }; }; }; +export interface ContractorPaymentsHistoricalPaymentsCreateHistoricalPayment{ +"title":string; +"description":string; +"emptyTableTitle":string; +"emptyTableDescription":string; +"breadcrumbLabel":string; +"dateLabel":string; +"dateHint":string; +"hoursAndPaymentsLabel":string; +"contractorTableHeaders":{ +"contractor":string; +"wageType":string; +"hours":string; +"wage":string; +"bonus":string; +"reimbursement":string; +"total":string; +}; +"na":string; +"totalsLabel":string; +"backButton":string; +"continueCta":string; +"editContractor":string; +"perHour":string; +"wageTypes":{ +"fixed":string; +"hourly":string; +}; +"alerts":{ +"contractorPaymentUpdated":string; +"noContractorPayments":string; +"dateMustBeInPast":string; +}; +"editContractorPayment":{ +"title":string; +"subtitle":string; +"hoursSection":string; +"hoursLabel":string; +"hoursAdornment":string; +"fixedPaySection":string; +"wageLabel":string; +"additionalEarningsSection":string; +"bonusLabel":string; +"reimbursementLabel":string; +"totalPay":string; +"cancelCta":string; +"saveCta":string; +}; +"previewPresentation":{ +"breadcrumbLabel":string; +"reviewAndSubmitTitle":string; +"reviewSubtitle":string; +"summaryTableHeaders":{ +"totalAmount":string; +"contractorPayDate":string; +}; +"paymentSummaryTitle":string; +"contractorTableHeaders":{ +"contractor":string; +"wageType":string; +"hours":string; +"wage":string; +"bonus":string; +"reimbursement":string; +"total":string; +}; +"totalsLabel":string; +"editButton":string; +"submitButton":string; +"perHour":string; +"wageTypes":{ +"fixed":string; +"hourly":string; +}; +}; +"successPresentation":{ +"successTitle":string; +"successMessage":string; +"summaryTitle":string; +"summarySubtitle":string; +"doneCta":string; +"summaryTableHeaders":{ +"totalAmount":string; +"contractorPayDate":string; +}; +"contractorTableHeaders":{ +"contractor":string; +"wageType":string; +"hours":string; +"wage":string; +"bonus":string; +"reimbursement":string; +"total":string; +}; +"totalsLabel":string; +"perHour":string; +"wageTypes":{ +"fixed":string; +"hourly":string; +}; +}; +}; +export interface ContractorPaymentsHistoricalPaymentsHistoricalPaymentFlow{ +"breadcrumbLabel":string; +}; export interface ContractorPaymentsPaymentHistory{ "title":string; "subtitle":string; @@ -3179,6 +3284,6 @@ export interface common{ interface CustomTypeOptions { defaultNS: 'common'; - resources: { 'Company.Addresses': CompanyAddresses, 'Company.AssignSignatory': CompanyAssignSignatory, 'Company.BankAccount': CompanyBankAccount, 'Company.DocumentList': CompanyDocumentList, 'Company.FederalTaxes': CompanyFederalTaxes, 'Company.Industry': CompanyIndustry, 'Company.Locations': CompanyLocations, 'Company.OnboardingOverview': CompanyOnboardingOverview, 'Company.PaySchedule': CompanyPaySchedule, 'Company.SignatureForm': CompanySignatureForm, 'Company.StateTaxes': CompanyStateTaxes, 'Company.TimeOff.CreateTimeOffPolicy': CompanyTimeOffCreateTimeOffPolicy, 'Company.TimeOff.EmployeeTable': CompanyTimeOffEmployeeTable, 'Company.TimeOff.HolidayPolicy': CompanyTimeOffHolidayPolicy, 'Company.TimeOff.TimeOffPolicies': CompanyTimeOffTimeOffPolicies, 'Company.TimeOff.TimeOffPolicyDetails': CompanyTimeOffTimeOffPolicyDetails, 'Company.TimeOff.TimeOffRequests': CompanyTimeOffTimeOffRequests, 'Contractor.Address': ContractorAddress, 'Contractor.ContractorList': ContractorContractorList, 'Contractor.NewHireReport': ContractorNewHireReport, 'Contractor.PaymentMethod': ContractorPaymentMethod, 'Contractor.Payments.CreatePayment': ContractorPaymentsCreatePayment, 'Contractor.Payments.PaymentHistory': ContractorPaymentsPaymentHistory, 'Contractor.Payments.PaymentStatement': ContractorPaymentsPaymentStatement, 'Contractor.Payments.PaymentSummary': ContractorPaymentsPaymentSummary, 'Contractor.Payments.PaymentsList': ContractorPaymentsPaymentsList, 'Contractor.Profile': ContractorProfile, 'Contractor.Submit': ContractorSubmit, 'Employee.BankAccount': EmployeeBankAccount, 'Employee.Compensation': EmployeeCompensation, 'Employee.Deductions': EmployeeDeductions, 'Employee.DocumentSigner': EmployeeDocumentSigner, 'Employee.EmployeeDocuments': EmployeeEmployeeDocuments, 'Employee.EmployeeList': EmployeeEmployeeList, 'Employee.EmploymentEligibility': EmployeeEmploymentEligibility, 'Employee.FederalTaxes': EmployeeFederalTaxes, 'Employee.HomeAddress': EmployeeHomeAddress, 'Employee.I9SignatureForm': EmployeeI9SignatureForm, 'Employee.Landing': EmployeeLanding, 'Employee.ManagementEmployeeList': EmployeeManagementEmployeeList, 'Employee.OnboardingSummary': EmployeeOnboardingSummary, 'Employee.PaySchedules': EmployeePaySchedules, 'Employee.PaymentMethod': EmployeePaymentMethod, 'Employee.Profile': EmployeeProfile, 'Employee.SplitPaycheck': EmployeeSplitPaycheck, 'Employee.StateTaxes': EmployeeStateTaxes, 'Employee.Taxes': EmployeeTaxes, 'Employee.Terminations.TerminateEmployee': EmployeeTerminationsTerminateEmployee, 'Employee.Terminations.TerminationFlow': EmployeeTerminationsTerminationFlow, 'Employee.Terminations.TerminationSummary': EmployeeTerminationsTerminationSummary, 'InformationRequests.InformationRequestForm': InformationRequestsInformationRequestForm, 'InformationRequests.InformationRequestList': InformationRequestsInformationRequestList, 'InformationRequests': InformationRequests, 'Payroll.Common': PayrollCommon, 'Payroll.ConfirmWireDetailsBanner': PayrollConfirmWireDetailsBanner, 'Payroll.ConfirmWireDetailsForm': PayrollConfirmWireDetailsForm, 'Payroll.Dismissal': PayrollDismissal, 'Payroll.EmployeeSelection': PayrollEmployeeSelection, 'Payroll.GrossUpModal': PayrollGrossUpModal, 'Payroll.OffCycle': PayrollOffCycle, 'Payroll.OffCycleCreation': PayrollOffCycleCreation, 'Payroll.OffCycleDeductionsSetting': PayrollOffCycleDeductionsSetting, 'Payroll.OffCyclePayPeriodDateForm': PayrollOffCyclePayPeriodDateForm, 'Payroll.OffCycleReasonSelection': PayrollOffCycleReasonSelection, 'Payroll.OffCycleTaxWithholding': PayrollOffCycleTaxWithholding, 'Payroll.PayrollBlocker': PayrollPayrollBlocker, 'Payroll.PayrollConfiguration': PayrollPayrollConfiguration, 'Payroll.PayrollEditEmployee': PayrollPayrollEditEmployee, 'Payroll.PayrollFlow': PayrollPayrollFlow, 'Payroll.PayrollHistory': PayrollPayrollHistory, 'Payroll.PayrollLanding': PayrollPayrollLanding, 'Payroll.PayrollList': PayrollPayrollList, 'Payroll.PayrollOverview': PayrollPayrollOverview, 'Payroll.PayrollReceipts': PayrollPayrollReceipts, 'Payroll.RecoveryCasesList': PayrollRecoveryCasesList, 'Payroll.RecoveryCasesResubmit': PayrollRecoveryCasesResubmit, 'Payroll.Transition': PayrollTransition, 'Payroll.TransitionCreation': PayrollTransitionCreation, 'Payroll.TransitionPayrollAlert': PayrollTransitionPayrollAlert, 'Payroll.WireInstructions': PayrollWireInstructions, 'UNSTABLE.CompensationForm': UNSTABLECompensationForm, 'UNSTABLE.EmployeeDetailsForm': UNSTABLEEmployeeDetailsForm, 'UNSTABLE.WorkAddressForm': UNSTABLEWorkAddressForm, 'common': common, } + resources: { 'Company.Addresses': CompanyAddresses, 'Company.AssignSignatory': CompanyAssignSignatory, 'Company.BankAccount': CompanyBankAccount, 'Company.DocumentList': CompanyDocumentList, 'Company.FederalTaxes': CompanyFederalTaxes, 'Company.Industry': CompanyIndustry, 'Company.Locations': CompanyLocations, 'Company.OnboardingOverview': CompanyOnboardingOverview, 'Company.PaySchedule': CompanyPaySchedule, 'Company.SignatureForm': CompanySignatureForm, 'Company.StateTaxes': CompanyStateTaxes, 'Company.TimeOff.CreateTimeOffPolicy': CompanyTimeOffCreateTimeOffPolicy, 'Company.TimeOff.EmployeeTable': CompanyTimeOffEmployeeTable, 'Company.TimeOff.HolidayPolicy': CompanyTimeOffHolidayPolicy, 'Company.TimeOff.TimeOffPolicies': CompanyTimeOffTimeOffPolicies, 'Company.TimeOff.TimeOffPolicyDetails': CompanyTimeOffTimeOffPolicyDetails, 'Company.TimeOff.TimeOffRequests': CompanyTimeOffTimeOffRequests, 'Contractor.Address': ContractorAddress, 'Contractor.ContractorList': ContractorContractorList, 'Contractor.NewHireReport': ContractorNewHireReport, 'Contractor.PaymentMethod': ContractorPaymentMethod, 'Contractor.Payments.CreatePayment': ContractorPaymentsCreatePayment, 'Contractor.Payments.HistoricalPayments.CreateHistoricalPayment': ContractorPaymentsHistoricalPaymentsCreateHistoricalPayment, 'Contractor.Payments.HistoricalPayments.HistoricalPaymentFlow': ContractorPaymentsHistoricalPaymentsHistoricalPaymentFlow, 'Contractor.Payments.PaymentHistory': ContractorPaymentsPaymentHistory, 'Contractor.Payments.PaymentStatement': ContractorPaymentsPaymentStatement, 'Contractor.Payments.PaymentSummary': ContractorPaymentsPaymentSummary, 'Contractor.Payments.PaymentsList': ContractorPaymentsPaymentsList, 'Contractor.Profile': ContractorProfile, 'Contractor.Submit': ContractorSubmit, 'Employee.BankAccount': EmployeeBankAccount, 'Employee.Compensation': EmployeeCompensation, 'Employee.Deductions': EmployeeDeductions, 'Employee.DocumentSigner': EmployeeDocumentSigner, 'Employee.EmployeeDocuments': EmployeeEmployeeDocuments, 'Employee.EmployeeList': EmployeeEmployeeList, 'Employee.EmploymentEligibility': EmployeeEmploymentEligibility, 'Employee.FederalTaxes': EmployeeFederalTaxes, 'Employee.HomeAddress': EmployeeHomeAddress, 'Employee.I9SignatureForm': EmployeeI9SignatureForm, 'Employee.Landing': EmployeeLanding, 'Employee.ManagementEmployeeList': EmployeeManagementEmployeeList, 'Employee.OnboardingSummary': EmployeeOnboardingSummary, 'Employee.PaySchedules': EmployeePaySchedules, 'Employee.PaymentMethod': EmployeePaymentMethod, 'Employee.Profile': EmployeeProfile, 'Employee.SplitPaycheck': EmployeeSplitPaycheck, 'Employee.StateTaxes': EmployeeStateTaxes, 'Employee.Taxes': EmployeeTaxes, 'Employee.Terminations.TerminateEmployee': EmployeeTerminationsTerminateEmployee, 'Employee.Terminations.TerminationFlow': EmployeeTerminationsTerminationFlow, 'Employee.Terminations.TerminationSummary': EmployeeTerminationsTerminationSummary, 'InformationRequests.InformationRequestForm': InformationRequestsInformationRequestForm, 'InformationRequests.InformationRequestList': InformationRequestsInformationRequestList, 'InformationRequests': InformationRequests, 'Payroll.Common': PayrollCommon, 'Payroll.ConfirmWireDetailsBanner': PayrollConfirmWireDetailsBanner, 'Payroll.ConfirmWireDetailsForm': PayrollConfirmWireDetailsForm, 'Payroll.Dismissal': PayrollDismissal, 'Payroll.EmployeeSelection': PayrollEmployeeSelection, 'Payroll.GrossUpModal': PayrollGrossUpModal, 'Payroll.OffCycle': PayrollOffCycle, 'Payroll.OffCycleCreation': PayrollOffCycleCreation, 'Payroll.OffCycleDeductionsSetting': PayrollOffCycleDeductionsSetting, 'Payroll.OffCyclePayPeriodDateForm': PayrollOffCyclePayPeriodDateForm, 'Payroll.OffCycleReasonSelection': PayrollOffCycleReasonSelection, 'Payroll.OffCycleTaxWithholding': PayrollOffCycleTaxWithholding, 'Payroll.PayrollBlocker': PayrollPayrollBlocker, 'Payroll.PayrollConfiguration': PayrollPayrollConfiguration, 'Payroll.PayrollEditEmployee': PayrollPayrollEditEmployee, 'Payroll.PayrollFlow': PayrollPayrollFlow, 'Payroll.PayrollHistory': PayrollPayrollHistory, 'Payroll.PayrollLanding': PayrollPayrollLanding, 'Payroll.PayrollList': PayrollPayrollList, 'Payroll.PayrollOverview': PayrollPayrollOverview, 'Payroll.PayrollReceipts': PayrollPayrollReceipts, 'Payroll.RecoveryCasesList': PayrollRecoveryCasesList, 'Payroll.RecoveryCasesResubmit': PayrollRecoveryCasesResubmit, 'Payroll.Transition': PayrollTransition, 'Payroll.TransitionCreation': PayrollTransitionCreation, 'Payroll.TransitionPayrollAlert': PayrollTransitionPayrollAlert, 'Payroll.WireInstructions': PayrollWireInstructions, 'UNSTABLE.CompensationForm': UNSTABLECompensationForm, 'UNSTABLE.EmployeeDetailsForm': UNSTABLEEmployeeDetailsForm, 'UNSTABLE.WorkAddressForm': UNSTABLEWorkAddressForm, 'common': common, } }; } \ No newline at end of file