-
Notifications
You must be signed in to change notification settings - Fork 5
feat: Historical Contractor Payments SDK (AI pilot experiment) #1476
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 ( | ||
| <BaseComponent | ||
| {...props} | ||
| componentName="Contractor.Payments.HistoricalPayments.CreateHistoricalPayment" | ||
| > | ||
| <Root {...props}>{props.children}</Root> | ||
| </BaseComponent> | ||
| ) | ||
| } | ||
|
|
||
| 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<Record<string, InternalAlert>>({}) | ||
| const [previewData, setPreviewData] = useState<ContractorPaymentGroupPreview | null>(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], | ||
| ) | ||
|
Comment on lines
+73
to
+111
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think there's some reworking here that could take place from the mapping -> virtualization. i think we could update this to be driven by the modal submission rather than needing to memoize |
||
|
|
||
| const formMethods = useForm<EditHistoricalPaymentFormValues>({ | ||
| resolver: zodResolver(createEditHistoricalPaymentFormSchema()), | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We likely don't need a create function on the schema for this one |
||
| 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 }, | ||
| ) | ||
|
Comment on lines
+160
to
+171
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think we can update this to conditionally render the modal so we get the form reset out of the box rather than trying to reset form state. There are some issues that can happen with forms that are tough to track down with the attempted manual reset |
||
| 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( | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I believe we already have a first/last name utility that is using DOMPurify under the hood |
||
| 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] | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This should be able to use an existing date util |
||
| 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 && ( | ||
| <HistoricalPreviewPresentation | ||
| contractorPaymentGroup={previewData} | ||
| contractors={contractors} | ||
| onBackToEdit={onBackToEdit} | ||
| onSubmit={onCreatePaymentGroup} | ||
| isLoading={isCreatingContractorPaymentGroup || isPreviewingContractorPaymentGroup} | ||
| /> | ||
| )} | ||
| {!previewData && ( | ||
| <CreateHistoricalPaymentPresentation | ||
| contractors={contractors} | ||
| contractorPayments={virtualContractorPayments} | ||
| paymentDate={paymentDate} | ||
| onPaymentDateChange={setPaymentDate} | ||
| onSaveAndContinue={onContinueToPreview} | ||
| onEditContractor={onEditContractor} | ||
| totals={totals} | ||
| alerts={alerts} | ||
| isLoading={isCreatingContractorPaymentGroup || isPreviewingContractorPaymentGroup} | ||
| /> | ||
| )} | ||
|
Comment on lines
+281
to
+302
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think this would need more thought from the eng side of things on how to compose these components. Having CreateHistoricalPayment fork on the preview or the create historical payment is coupling these things too tightly. I think we'd likely want the preview as a dedicated component living as a separate entry in the state machine and then we would navigate to that preview based on an event fired from this component. As is, this component is doing too much |
||
| <EditHistoricalPaymentPresentation | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. i think the rec here would be to supply a unique key here that can be incremented after update or similar approach to unmount the form rather than needing to run the reset |
||
| isOpen={isModalOpen} | ||
| onClose={() => { | ||
| setIsModalOpen(false) | ||
| }} | ||
| formMethods={formMethods} | ||
| onSubmit={onEditContractorSubmit} | ||
| /> | ||
| </> | ||
| ) | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think we would want to create a separate dedicated domain for this,
Contractor.HistoricalPaymentsrather than nesting this many values on the translation key