From e25098bc79e2def1db6b9e47b6b06274bce4d159 Mon Sep 17 00:00:00 2001 From: Jordi Vidaller Date: Thu, 17 Sep 2026 13:17:16 +0200 Subject: [PATCH 01/17] refactor(contract-documents): move the contract-details helpers to common The pieces the contract details step is built from move out of ContractorOnboarding so the standalone ContractDocument flow can share them: the create-contract-document mutation, `buildContractDetailsJsfModify`, the AI validation error extraction and messages, the contractor product identifiers, `isCMOrCMPlus`, `calculateProvisionalStartDateDescription` and `StatementOfWorkDisclaimer`. No behaviour change: onboarding imports the moved symbols from their new home, the public product identifiers are still exported from the ContractorOnboarding entry, and the onboarding hook now calls the shared `extractAiValidationError` instead of its inline copy. Co-Authored-By: Claude Fable 5.1 --- src/common/contract-documents/api.ts | 35 +++- .../components/StatementOfWorkDisclaimer.tsx | 2 +- src/common/contract-documents/constants.ts | 14 ++ src/common/contract-documents/index.ts | 27 ++- src/common/contract-documents/jsfModify.tsx | 152 +++++++++++++++++ src/common/contract-documents/types.ts | 28 ++++ src/common/contract-documents/utils.ts | 81 +++++++++ src/flows/ContractorOnboarding/api.ts | 32 +--- .../components/ContractDetailsStep.tsx | 4 +- src/flows/ContractorOnboarding/constants.ts | 20 +-- src/flows/ContractorOnboarding/hooks.tsx | 71 ++------ src/flows/ContractorOnboarding/index.ts | 8 +- src/flows/ContractorOnboarding/jsfModify.tsx | 154 +----------------- .../ContractorOnboarding/tests/utils.test.ts | 3 +- src/flows/ContractorOnboarding/types.ts | 36 +--- src/flows/ContractorOnboarding/utils.ts | 41 ----- 16 files changed, 379 insertions(+), 329 deletions(-) rename src/{flows/ContractorOnboarding => common/contract-documents}/components/StatementOfWorkDisclaimer.tsx (96%) create mode 100644 src/common/contract-documents/constants.ts create mode 100644 src/common/contract-documents/jsfModify.tsx create mode 100644 src/common/contract-documents/types.ts create mode 100644 src/common/contract-documents/utils.ts diff --git a/src/common/contract-documents/api.ts b/src/common/contract-documents/api.ts index 1305353d3..b9d1153fa 100644 --- a/src/common/contract-documents/api.ts +++ b/src/common/contract-documents/api.ts @@ -1,5 +1,9 @@ -import { queryOptions, useQuery } from '@tanstack/react-query'; -import { getV1EmploymentsEmploymentIdContractDocuments } from '@/src/client'; +import { queryOptions, useMutation, useQuery } from '@tanstack/react-query'; +import { + CreateContractDocument, + getV1EmploymentsEmploymentIdContractDocuments, + postV1ContractorsEmploymentsEmploymentIdContractDocuments, +} from '@/src/client'; import { Client } from '@/src/client/client'; import { useClient } from '@/src/context'; @@ -39,3 +43,30 @@ export const useGetContractDocuments = ( select: ({ data }) => data?.data?.contract_documents, }); }; + +/** + * Saves the contractor details data + * @param employmentId - The employment ID + * @param payload - The payload + * @returns The contractor contract document + */ +export const useCreateContractorContractDocument = () => { + const { client } = useClient(); + return useMutation({ + mutationFn: async ({ + employmentId, + payload, + }: { + employmentId: string; + payload: CreateContractDocument; + }) => { + return postV1ContractorsEmploymentsEmploymentIdContractDocuments({ + client: client as Client, + body: payload, + path: { + employment_id: employmentId, + }, + }); + }, + }); +}; diff --git a/src/flows/ContractorOnboarding/components/StatementOfWorkDisclaimer.tsx b/src/common/contract-documents/components/StatementOfWorkDisclaimer.tsx similarity index 96% rename from src/flows/ContractorOnboarding/components/StatementOfWorkDisclaimer.tsx rename to src/common/contract-documents/components/StatementOfWorkDisclaimer.tsx index 596b47c79..67cfb3a82 100644 --- a/src/flows/ContractorOnboarding/components/StatementOfWorkDisclaimer.tsx +++ b/src/common/contract-documents/components/StatementOfWorkDisclaimer.tsx @@ -2,7 +2,7 @@ import { Statement } from '@/src/components/form/Statement'; import { contractorPlusProductIdentifier, contractorStandardProductIdentifier, -} from '@/src/flows/ContractorOnboarding/constants'; +} from '@/src/common/contract-documents/constants'; const CSA_DISCLAIMER = { [contractorStandardProductIdentifier]: diff --git a/src/common/contract-documents/constants.ts b/src/common/contract-documents/constants.ts new file mode 100644 index 000000000..695bbde2c --- /dev/null +++ b/src/common/contract-documents/constants.ts @@ -0,0 +1,14 @@ +export const contractorStandardProductIdentifier = + 'urn:remotecom:resource:product:contractor:standard:monthly'; +export const contractorPlusProductIdentifier = + 'urn:remotecom:resource:product:contractor:plus:monthly'; +export const corProductIdentifier = + 'urn:remotecom:resource:product:contractor:aor:monthly'; + +export const REMOTE_AI_ERROR_SOURCE = 'REMOTE_AI'; + +export const REMOTE_AI_SERVICES_AND_DELIVERABLES_ERROR_MESSAGE = + "You cannot control how a contractor completes the Services and Deliverables. This means, for example, that you can't specify their working hours or use of subcontractors. Please make sure this field includes only project names, descriptions, and deliverables. We do not allow hyperlinks."; + +export const REMOTE_AI_SERVICES_AND_DELIVERABLES_COR_ERROR_MESSAGE = + 'The content You have entered may not be consistent with the Contractor of Record terms. You are responsible before proceeding for ensuring that the language entered herein accurately reflects those terms and your relationship with Subcontractors.'; diff --git a/src/common/contract-documents/index.ts b/src/common/contract-documents/index.ts index 071d0b72f..c28a9870f 100644 --- a/src/common/contract-documents/index.ts +++ b/src/common/contract-documents/index.ts @@ -1 +1,26 @@ -export { contractDocumentsOptions, useGetContractDocuments } from './api'; +export { + contractDocumentsOptions, + useCreateContractorContractDocument, + useGetContractDocuments, +} from './api'; +export { + contractorPlusProductIdentifier, + contractorStandardProductIdentifier, + corProductIdentifier, + REMOTE_AI_ERROR_SOURCE, + REMOTE_AI_SERVICES_AND_DELIVERABLES_COR_ERROR_MESSAGE, + REMOTE_AI_SERVICES_AND_DELIVERABLES_ERROR_MESSAGE, +} from './constants'; +export { buildContractDetailsJsfModify } from './jsfModify'; +export { + calculateProvisionalStartDateDescription, + extractAiValidationError, + isCMOrCMPlus, + transformAiErrorResponse, +} from './utils'; +export type { + AiValidationError, + ContractorContractDetailsFormPayload, + ContractorContractDetailsResponse, +} from './types'; +export { StatementOfWorkDisclaimer } from './components/StatementOfWorkDisclaimer'; diff --git a/src/common/contract-documents/jsfModify.tsx b/src/common/contract-documents/jsfModify.tsx new file mode 100644 index 000000000..a82b998be --- /dev/null +++ b/src/common/contract-documents/jsfModify.tsx @@ -0,0 +1,152 @@ +import { addYears, format } from 'date-fns'; +import { FieldValues } from 'react-hook-form'; +import { ChangeEvent } from 'react'; + +import { parseLocalDate } from '@/src/common/dates'; +import { createStatementProperty } from '@/src/components/form/jsf-utils/createFields'; +import { + contractorStandardProductIdentifier, + REMOTE_AI_SERVICES_AND_DELIVERABLES_COR_ERROR_MESSAGE, + REMOTE_AI_SERVICES_AND_DELIVERABLES_ERROR_MESSAGE, +} from '@/src/common/contract-documents/constants'; +import { ContractorContractDetailsFormPayload } from '@/src/common/contract-documents/types'; +import { JSFModify } from '@/src/flows/types'; + +const isStandardPricingPlan = (pricingPlan: string | undefined) => { + return pricingPlan === contractorStandardProductIdentifier; +}; + +const showBackDateWarning = ( + isStandardPricingPlanSelected: boolean, + provisionalStartDate: string | undefined, +) => { + const isStartDateBackdated = + provisionalStartDate && + // Compare full days omitting time of the day + provisionalStartDate < format(new Date(), 'yyyy-MM-dd'); + + if (!isStandardPricingPlanSelected && isStartDateBackdated) { + return createStatementProperty({ + severity: 'warning', + description: + 'Backdating the service start date is not supported in the selected Contractor Management plan.', + }); + } + + return undefined; +}; + +/** + * Handles changes to the services_and_deliverables field to clear AI warning state + */ +function onServicesAndDeliverablesChange( + _event: ChangeEvent, + values: ContractorContractDetailsFormPayload & { + services_and_deliverables_ai_warning: string; + services_and_deliverables_error_skippable: boolean; + }, + setValues: ( + formValues: Partial< + ContractorContractDetailsFormPayload & { + services_and_deliverables_ai_warning: string; + services_and_deliverables_error_skippable: boolean; + } + >, + ) => void, +) { + setValues({ + ...values, + services_and_deliverables_ai_warning: '', + services_and_deliverables_error_skippable: false, + }); +} + +/** + * Merges internal jsfModify modifications with user-provided options for contract_details step + * This abstracts the logic of applying internal field modifications (like dynamic descriptions) + * while preserving user customizations + */ +export const buildContractDetailsJsfModify = ( + userJsfModify: JSFModify | undefined, + provisionalStartDateDescription: string | undefined, + selectedPricingPlan: string | undefined, + fieldValues: FieldValues, + isContractorOfRecord: boolean, +): JSFModify => { + const isStandardPricingPlanSelected = + isStandardPricingPlan(selectedPricingPlan); + const provisionalStartDate = + fieldValues?.service_duration?.provisional_start_date; + const statement = showBackDateWarning( + isStandardPricingPlanSelected, + provisionalStartDate, + ); + const AiStatementWarning = createStatementProperty({ + severity: 'warning', + title: 'Possible misclassification risk', + description: isContractorOfRecord + ? REMOTE_AI_SERVICES_AND_DELIVERABLES_COR_ERROR_MESSAGE + : REMOTE_AI_SERVICES_AND_DELIVERABLES_ERROR_MESSAGE, + }); + return { + ...userJsfModify, + create: { + ...userJsfModify?.create, + services_and_deliverables_ai_warning: { + type: 'string', + 'x-jsf-presentation': { + inputType: 'hidden', + }, + }, + services_and_deliverables_error_skippable: { + type: 'boolean', + 'x-jsf-presentation': { + inputType: 'hidden', + }, + }, + }, + fields: { + ...userJsfModify?.fields, + ...{ + 'service_duration.provisional_start_date': { + description: provisionalStartDateDescription, + 'x-jsf-presentation': { + minDate: !isStandardPricingPlanSelected + ? format(new Date(), 'yyyy-MM-dd') + : undefined, + ...statement, + }, + }, + 'service_duration.expiration_date': { + 'x-jsf-presentation': { + calculateDynamicProperties: (formValues: FieldValues) => { + const maxDate = + isContractorOfRecord && + formValues.service_duration?.provisional_start_date + ? addYears( + parseLocalDate( + formValues.service_duration.provisional_start_date, + ), + 1, + ) + : undefined; + return { + maxDate: maxDate ? format(maxDate, 'yyyy-MM-dd') : undefined, + }; + }, + }, + }, + services_and_deliverables: { + onChange: onServicesAndDeliverablesChange, + 'x-jsf-presentation': { + calculateDynamicProperties: (formValues: FieldValues) => ({ + statement: formValues.services_and_deliverables_ai_warning + ? AiStatementWarning.statement + : undefined, + }), + }, + }, + }, + }, + }; +}; diff --git a/src/common/contract-documents/types.ts b/src/common/contract-documents/types.ts new file mode 100644 index 000000000..f395f25cc --- /dev/null +++ b/src/common/contract-documents/types.ts @@ -0,0 +1,28 @@ +import { CreateContractDocumentResponse } from '@/src/client/types.gen'; + +export type ContractorContractDetailsFormPayload = { + services_and_deliverables: string; + service_duration: { + expiration_date?: string; + provisional_start_date: string; + }; + termination: { + contractor_notice_period_amount: number; + company_notice_period_amount: number; + }; + payment_terms: { + payment_terms_type: string; + invoicing_frequency: string; + compensation_gross_amount: string; + compensation_currency_code?: string; + period_unit: string; + }; +}; + +export type ContractorContractDetailsResponse = CreateContractDocumentResponse; + +export type AiValidationError = { + error: string[]; + source: string; + skippable: boolean; +}; diff --git a/src/common/contract-documents/utils.ts b/src/common/contract-documents/utils.ts new file mode 100644 index 000000000..0ffacf3f8 --- /dev/null +++ b/src/common/contract-documents/utils.ts @@ -0,0 +1,81 @@ +import { + contractorPlusProductIdentifier, + contractorStandardProductIdentifier, + REMOTE_AI_ERROR_SOURCE, + REMOTE_AI_SERVICES_AND_DELIVERABLES_COR_ERROR_MESSAGE, + REMOTE_AI_SERVICES_AND_DELIVERABLES_ERROR_MESSAGE, +} from '@/src/common/contract-documents/constants'; +import { AiValidationError } from '@/src/common/contract-documents/types'; +import { isMutationError } from '@/src/lib/mutations'; + +export const calculateProvisionalStartDateDescription = ( + employmentProvisionalStartDate: string | undefined, + fieldProvisionalStartDate: string | undefined, +): string | undefined => { + const datesNotMatching = + employmentProvisionalStartDate && + fieldProvisionalStartDate && + employmentProvisionalStartDate !== fieldProvisionalStartDate; + + if (datesNotMatching) { + const datesDontMatchWarning = `This date does not match the date you provided in the Basic Information step - ${ + employmentProvisionalStartDate + } - and will override it only when both parties have signed the contract.`; + return `When the contractor will start providing service to your company. ${datesDontMatchWarning}`; + } + return undefined; +}; + +/** + * Checks if the selected pricing plan is CM (standard) or CM+ (plus) + */ +export const isCMOrCMPlus = (subscription: string | undefined): boolean => { + return ( + subscription === contractorStandardProductIdentifier || + subscription === contractorPlusProductIdentifier + ); +}; + +export function transformAiErrorResponse( + isContractorOfRecord: boolean, +): string { + const remoteAiErrorMessage = isContractorOfRecord + ? REMOTE_AI_SERVICES_AND_DELIVERABLES_COR_ERROR_MESSAGE + : REMOTE_AI_SERVICES_AND_DELIVERABLES_ERROR_MESSAGE; + return remoteAiErrorMessage; +} + +/** + * Extracts the AI validation error from a failed contract-document creation, if that is + * what the failure was. + */ +export const extractAiValidationError = ( + error: unknown, +): AiValidationError | null => { + if (!isMutationError(error)) { + return null; + } + + const rawError = error.normalizedErrors.services_and_deliverables; + + // The backend's normalize_errors wraps non-list values in an array, + // so the AI validation error object arrives as a single-element array. + const servicesAndDeliverablesError = ( + Array.isArray(rawError) ? rawError[0] : rawError + ) as + | { + error: string[]; + source: string; + skippable: boolean; + } + | undefined; + + if (servicesAndDeliverablesError?.source === REMOTE_AI_ERROR_SOURCE) { + return { + error: servicesAndDeliverablesError.error, + source: servicesAndDeliverablesError.source, + skippable: servicesAndDeliverablesError.skippable, + }; + } + return null; +}; diff --git a/src/flows/ContractorOnboarding/api.ts b/src/flows/ContractorOnboarding/api.ts index a8b14eafe..103298d62 100644 --- a/src/flows/ContractorOnboarding/api.ts +++ b/src/flows/ContractorOnboarding/api.ts @@ -1,12 +1,10 @@ import { useMemo } from 'react'; import { CompanyAction, - CreateContractDocument, getV1CompaniesCompanyIdActions, getV1ContractorsEmploymentsEmploymentIdContractDocumentsId, getV1ContractorsEmploymentsEmploymentIdContractorSubscriptions, ManageContractorPlusSubscriptionOperationsParams, - postV1ContractorsEmploymentsEmploymentIdContractDocuments, postV1ContractorsEmploymentsEmploymentIdContractorPlusSubscription, postV1ContractorsEmploymentsEmploymentIdContractDocumentsContractDocumentIdSign, SignContractDocument, @@ -21,6 +19,7 @@ import { PostV1EmploymentsEmploymentIdContractOriginData, } from '@/src/client'; import { useClient } from '@/src/context'; +import { useCreateContractorContractDocument } from '@/src/common/contract-documents/api'; import { signatureSchema } from '@/src/flows/ContractorOnboarding/json-schemas/signature'; import { contractOriginSchema } from '@/src/flows/ContractorOnboarding/json-schemas/contractOrigin'; import { invoiceScheduleSchema } from '@/src/flows/ContractorOnboarding/json-schemas/invoiceSchedule'; @@ -39,6 +38,8 @@ import { contractorPlusProductIdentifier, contractorStandardProductIdentifier, corProductIdentifier, +} from '@/src/common/contract-documents/constants'; +import { eorProductIdentifier, FEATURES_BY_IDENTIFIER, IR35_FILE_SUBTYPE, @@ -244,33 +245,6 @@ export const usePostManageContractorSubscriptions = () => { }); }; -/** - * Saves the contractor details data - * @param employmentId - The employment ID - * @param payload - The payload - * @returns The contractor contract document - */ -export const useCreateContractorContractDocument = () => { - const { client } = useClient(); - return useMutation({ - mutationFn: async ({ - employmentId, - payload, - }: { - employmentId: string; - payload: CreateContractDocument; - }) => { - return postV1ContractorsEmploymentsEmploymentIdContractDocuments({ - client: client as Client, - body: payload, - path: { - employment_id: employmentId, - }, - }); - }, - }); -}; - export const CONTRACT_PRODUCT_TITLES = { [contractorStandardProductIdentifier]: 'Contractor Management', [contractorPlusProductIdentifier]: 'Contractor Management Plus', diff --git a/src/flows/ContractorOnboarding/components/ContractDetailsStep.tsx b/src/flows/ContractorOnboarding/components/ContractDetailsStep.tsx index 202a7edff..7bf76768a 100644 --- a/src/flows/ContractorOnboarding/components/ContractDetailsStep.tsx +++ b/src/flows/ContractorOnboarding/components/ContractDetailsStep.tsx @@ -6,8 +6,8 @@ import { ContractorOnboardingContractDetailsFormPayload, ContractorOnboardingContractDetailsResponse, } from '@/src/flows/ContractorOnboarding/types'; -import { StatementOfWorkDisclaimer } from '@/src/flows/ContractorOnboarding/components/StatementOfWorkDisclaimer'; -import { isCMOrCMPlus } from '@/src/flows/ContractorOnboarding/utils'; +import { StatementOfWorkDisclaimer } from '@/src/common/contract-documents/components/StatementOfWorkDisclaimer'; +import { isCMOrCMPlus } from '@/src/common/contract-documents/utils'; import { handleStepError } from '@/src/lib/utils'; import { UseFormReturn } from 'react-hook-form'; diff --git a/src/flows/ContractorOnboarding/constants.ts b/src/flows/ContractorOnboarding/constants.ts index 4be129799..902f70f4f 100644 --- a/src/flows/ContractorOnboarding/constants.ts +++ b/src/flows/ContractorOnboarding/constants.ts @@ -1,9 +1,9 @@ -export const contractorStandardProductIdentifier = - 'urn:remotecom:resource:product:contractor:standard:monthly'; -export const contractorPlusProductIdentifier = - 'urn:remotecom:resource:product:contractor:plus:monthly'; -export const corProductIdentifier = - 'urn:remotecom:resource:product:contractor:aor:monthly'; +import { + contractorPlusProductIdentifier, + contractorStandardProductIdentifier, + corProductIdentifier, +} from '@/src/common/contract-documents/constants'; + export const eorProductIdentifier = 'urn:remotecom:resource:product:eor:monthly'; @@ -18,14 +18,6 @@ export const PRODUCT_IDENTIFIER_MAP: Record = { export const IR35_FILE_SUBTYPE = 'ir_35'; -export const REMOTE_AI_ERROR_SOURCE = 'REMOTE_AI'; - -export const REMOTE_AI_SERVICES_AND_DELIVERABLES_ERROR_MESSAGE = - "You cannot control how a contractor completes the Services and Deliverables. This means, for example, that you can't specify their working hours or use of subcontractors. Please make sure this field includes only project names, descriptions, and deliverables. We do not allow hyperlinks."; - -export const REMOTE_AI_SERVICES_AND_DELIVERABLES_COR_ERROR_MESSAGE = - 'The content You have entered may not be consistent with the Contractor of Record terms. You are responsible before proceeding for ensuring that the language entered herein accurately reflects those terms and your relationship with Subcontractors.'; - const standardOnboardingWorkflow = [ { title: 'You add a new contractor and their details in Remote.', diff --git a/src/flows/ContractorOnboarding/hooks.tsx b/src/flows/ContractorOnboarding/hooks.tsx index 669520cd7..5342e93a0 100644 --- a/src/flows/ContractorOnboarding/hooks.tsx +++ b/src/flows/ContractorOnboarding/hooks.tsx @@ -13,7 +13,6 @@ import { } from '@/src/components/form/utils'; import { ValidationResult } from '@remoteoss/remote-json-schema-form-kit'; import { - useCreateContractorContractDocument, useGetContractDocumentSignatureSchema, usePostManageContractorSubscriptions, useContractorSubscriptionSchemaField, @@ -40,14 +39,27 @@ import { useGetExistingInvoiceSchedule, } from '@/src/flows/ContractorOnboarding/api'; import { useContractorContractDetailsSchema } from '@/src/common/api/contractor-contract-details'; -import { useGetContractDocuments } from '@/src/common/contract-documents/api'; +import { + useCreateContractorContractDocument, + useGetContractDocuments, +} from '@/src/common/contract-documents/api'; +import { + contractorStandardProductIdentifier, + contractorPlusProductIdentifier, + corProductIdentifier, +} from '@/src/common/contract-documents/constants'; +import { buildContractDetailsJsfModify } from '@/src/common/contract-documents/jsfModify'; +import { + calculateProvisionalStartDateDescription, + extractAiValidationError, + transformAiErrorResponse, +} from '@/src/common/contract-documents/utils'; import { ContractorOnboardingFlowProps, ContractorOnboardingHookOptions, } from '@/src/flows/ContractorOnboarding/types'; import { buildSteps, - calculateProvisionalStartDateDescription, reviewStepAllowedEmploymentStatus, disabledInviteButtonEmploymentStatus, StepKeys, @@ -60,30 +72,19 @@ import { } from '@/src/flows/Onboarding/api'; import { FlowOptions, JSFModify, JSONSchemaFormType } from '@/src/flows/types'; import { useStepState } from '@/src/flows/useStepState'; -import { mutationToPromise, isMutationError } from '@/src/lib/mutations'; +import { mutationToPromise } from '@/src/lib/mutations'; import { clearBase64Data, createStructuredError, prettifyFormValues, } from '@/src/lib/utils'; import { JSFFieldset } from '@/src/types/remoteFlows'; -import { - contractorStandardProductIdentifier, - contractorPlusProductIdentifier, - corProductIdentifier, - eorProductIdentifier, - REMOTE_AI_ERROR_SOURCE, -} from '@/src/flows/ContractorOnboarding/constants'; +import { eorProductIdentifier } from '@/src/flows/ContractorOnboarding/constants'; import { buildBasicInformationJsfModify, - buildContractDetailsJsfModify, buildContractPreviewJsfModify, } from '@/src/flows/ContractorOnboarding/jsfModify'; -import { transformAiErrorResponse } from '@/src/flows/ContractorOnboarding/utils'; -import { - AiValidationError, - ContractorInvoicePreview, -} from '@/src/flows/ContractorOnboarding/types'; +import { ContractorInvoicePreview } from '@/src/flows/ContractorOnboarding/types'; import { useUploadFile } from '@/src/common/api/files'; import { dataURLtoFile } from '@/src/lib/files'; import { @@ -1274,42 +1275,6 @@ export const useContractorOnboarding = ({ return {}; }; - /** - * Extracts AI validation error from the error response - * @param error - The error object from the API call - * @returns The AI validation error if found, null otherwise - */ - const extractAiValidationError = ( - error: unknown, - ): AiValidationError | null => { - if (!isMutationError(error)) { - return null; - } - - const rawError = error.normalizedErrors.services_and_deliverables; - - // The backend's normalize_errors wraps non-list values in an array, - // so the AI validation error object arrives as a single-element array. - const servicesAndDeliverablesError = ( - Array.isArray(rawError) ? rawError[0] : rawError - ) as - | { - error: string[]; - source: string; - skippable: boolean; - } - | undefined; - - if (servicesAndDeliverablesError?.source === REMOTE_AI_ERROR_SOURCE) { - return { - error: servicesAndDeliverablesError.error, - source: servicesAndDeliverablesError.source, - skippable: servicesAndDeliverablesError.skippable, - }; - } - return null; - }; - async function onSubmit(values: FieldValues) { const currentStepName = stepState.currentStep.name; const parsedValues = await parseFormValues(values); diff --git a/src/flows/ContractorOnboarding/index.ts b/src/flows/ContractorOnboarding/index.ts index 72130a27b..334aa9ba3 100644 --- a/src/flows/ContractorOnboarding/index.ts +++ b/src/flows/ContractorOnboarding/index.ts @@ -17,10 +17,12 @@ export type { export type { ProductType } from './constants'; export { onboardingWorkflows, - contractorPlusProductIdentifier, - contractorStandardProductIdentifier, - corProductIdentifier, eorProductIdentifier, pricingPlanDetails, PRODUCT_IDENTIFIER_MAP, } from './constants'; +export { + contractorPlusProductIdentifier, + contractorStandardProductIdentifier, + corProductIdentifier, +} from '@/src/common/contract-documents/constants'; diff --git a/src/flows/ContractorOnboarding/jsfModify.tsx b/src/flows/ContractorOnboarding/jsfModify.tsx index f5ee8e3ce..3d2aba53b 100644 --- a/src/flows/ContractorOnboarding/jsfModify.tsx +++ b/src/flows/ContractorOnboarding/jsfModify.tsx @@ -1,166 +1,14 @@ -import { addYears, format } from 'date-fns'; import { FieldValues } from 'react-hook-form'; -import { ChangeEvent } from 'react'; -import { parseLocalDate } from '@/src/common/dates'; -import { createStatementProperty } from '@/src/components/form/jsf-utils/createFields'; import { zendeskArticles } from '@/src/components/shared/zendesk-drawer/utils'; import { ZendeskTriggerButton } from '@/src/components/shared/zendesk-drawer/ZendeskTriggerButton'; import { ContractPreviewHeader } from '@/src/flows/ContractorOnboarding/components/ContractPreviewHeader'; import { ContractPreviewStatement } from '@/src/flows/ContractorOnboarding/components/ContractPreviewStatement'; -import { - contractorStandardProductIdentifier, - REMOTE_AI_SERVICES_AND_DELIVERABLES_COR_ERROR_MESSAGE, - REMOTE_AI_SERVICES_AND_DELIVERABLES_ERROR_MESSAGE, -} from '@/src/flows/ContractorOnboarding/constants'; -import { - ContractorOnboardingFlowProps, - ContractorOnboardingContractDetailsFormPayload, -} from '@/src/flows/ContractorOnboarding/types'; +import { ContractorOnboardingFlowProps } from '@/src/flows/ContractorOnboarding/types'; import { isNationalityCountryCode } from '@/src/flows/ContractorOnboarding/utils'; -import { JSFModify } from '@/src/flows/types'; import { FILE_TYPES, MAX_FILE_SIZE } from '@/src/lib/uploadConfig'; import { JSFCustomComponentProps } from '@/src/types/remoteFlows'; -const isStandardPricingPlan = (pricingPlan: string | undefined) => { - return pricingPlan === contractorStandardProductIdentifier; -}; - -const showBackDateWarning = ( - isStandardPricingPlanSelected: boolean, - provisionalStartDate: string | undefined, -) => { - const isStartDateBackdated = - provisionalStartDate && - // Compare full days omitting time of the day - provisionalStartDate < format(new Date(), 'yyyy-MM-dd'); - - if (!isStandardPricingPlanSelected && isStartDateBackdated) { - return createStatementProperty({ - severity: 'warning', - description: - 'Backdating the service start date is not supported in the selected Contractor Management plan.', - }); - } - - return undefined; -}; - -/** - * Handles changes to the services_and_deliverables field to clear AI warning state - */ -function onServicesAndDeliverablesChange( - _event: ChangeEvent, - values: ContractorOnboardingContractDetailsFormPayload & { - services_and_deliverables_ai_warning: string; - services_and_deliverables_error_skippable: boolean; - }, - setValues: ( - formValues: Partial< - ContractorOnboardingContractDetailsFormPayload & { - services_and_deliverables_ai_warning: string; - services_and_deliverables_error_skippable: boolean; - } - >, - ) => void, -) { - setValues({ - ...values, - services_and_deliverables_ai_warning: '', - services_and_deliverables_error_skippable: false, - }); -} - -/** - * Merges internal jsfModify modifications with user-provided options for contract_details step - * This abstracts the logic of applying internal field modifications (like dynamic descriptions) - * while preserving user customizations - */ -export const buildContractDetailsJsfModify = ( - userJsfModify: JSFModify | undefined, - provisionalStartDateDescription: string | undefined, - selectedPricingPlan: string | undefined, - fieldValues: FieldValues, - isContractorOfRecord: boolean, -): JSFModify => { - const isStandardPricingPlanSelected = - isStandardPricingPlan(selectedPricingPlan); - const provisionalStartDate = - fieldValues?.service_duration?.provisional_start_date; - const statement = showBackDateWarning( - isStandardPricingPlanSelected, - provisionalStartDate, - ); - const AiStatementWarning = createStatementProperty({ - severity: 'warning', - title: 'Possible misclassification risk', - description: isContractorOfRecord - ? REMOTE_AI_SERVICES_AND_DELIVERABLES_COR_ERROR_MESSAGE - : REMOTE_AI_SERVICES_AND_DELIVERABLES_ERROR_MESSAGE, - }); - return { - ...userJsfModify, - create: { - ...userJsfModify?.create, - services_and_deliverables_ai_warning: { - type: 'string', - 'x-jsf-presentation': { - inputType: 'hidden', - }, - }, - services_and_deliverables_error_skippable: { - type: 'boolean', - 'x-jsf-presentation': { - inputType: 'hidden', - }, - }, - }, - fields: { - ...userJsfModify?.fields, - ...{ - 'service_duration.provisional_start_date': { - description: provisionalStartDateDescription, - 'x-jsf-presentation': { - minDate: !isStandardPricingPlanSelected - ? format(new Date(), 'yyyy-MM-dd') - : undefined, - ...statement, - }, - }, - 'service_duration.expiration_date': { - 'x-jsf-presentation': { - calculateDynamicProperties: (formValues: FieldValues) => { - const maxDate = - isContractorOfRecord && - formValues.service_duration?.provisional_start_date - ? addYears( - parseLocalDate( - formValues.service_duration.provisional_start_date, - ), - 1, - ) - : undefined; - return { - maxDate: maxDate ? format(maxDate, 'yyyy-MM-dd') : undefined, - }; - }, - }, - }, - services_and_deliverables: { - onChange: onServicesAndDeliverablesChange, - 'x-jsf-presentation': { - calculateDynamicProperties: (formValues: FieldValues) => ({ - statement: formValues.services_and_deliverables_ai_warning - ? AiStatementWarning.statement - : undefined, - }), - }, - }, - }, - }, - }; -}; - /** * Builds the basic information jsf modify for the contractor onboarding flow * @param countryCode - The country code to use for the onboarding. diff --git a/src/flows/ContractorOnboarding/tests/utils.test.ts b/src/flows/ContractorOnboarding/tests/utils.test.ts index 18f67176f..865e28e02 100644 --- a/src/flows/ContractorOnboarding/tests/utils.test.ts +++ b/src/flows/ContractorOnboarding/tests/utils.test.ts @@ -3,7 +3,8 @@ import { getBasicInformationSchemaVersion, buildInvoicePreviewPayload, } from '../utils'; -import { corProductIdentifier, eorProductIdentifier } from '../constants'; +import { eorProductIdentifier } from '../constants'; +import { corProductIdentifier } from '@/src/common/contract-documents/constants'; describe('shouldIncludeProduct', () => { it('should return true when excludeProducts is empty', () => { diff --git a/src/flows/ContractorOnboarding/types.ts b/src/flows/ContractorOnboarding/types.ts index 9a3e591a9..6dec92103 100644 --- a/src/flows/ContractorOnboarding/types.ts +++ b/src/flows/ContractorOnboarding/types.ts @@ -1,8 +1,8 @@ +import { SignContractDocument, SuccessResponse } from '@/src/client/types.gen'; import { - CreateContractDocumentResponse, - SignContractDocument, - SuccessResponse, -} from '@/src/client/types.gen'; + ContractorContractDetailsFormPayload, + ContractorContractDetailsResponse, +} from '@/src/common/contract-documents/types'; import { OnboardingBack } from '@/src/flows/ContractorOnboarding/components/OnboardingBack'; import { OnboardingSubmit } from '@/src/flows/ContractorOnboarding/components/OnboardingSubmit'; import { PricingPlanStep } from '@/src/flows/ContractorOnboarding/components/PricingPlan'; @@ -161,33 +161,11 @@ export type PricingPlanResponse = { subscription: string; }; -export type ContractorOnboardingContractDetailsFormPayload = { - services_and_deliverables: string; - service_duration: { - expiration_date?: string; - provisional_start_date: string; - }; - termination: { - contractor_notice_period_amount: number; - company_notice_period_amount: number; - }; - payment_terms: { - payment_terms_type: string; - invoicing_frequency: string; - compensation_gross_amount: string; - compensation_currency_code?: string; - period_unit: string; - }; -}; +export type ContractorOnboardingContractDetailsFormPayload = + ContractorContractDetailsFormPayload; export type ContractorOnboardingContractDetailsResponse = - CreateContractDocumentResponse; - -export type AiValidationError = { - error: string[]; - source: string; - skippable: boolean; -}; + ContractorContractDetailsResponse; export type ContractPreviewFormPayload = SignContractDocument; diff --git a/src/flows/ContractorOnboarding/utils.ts b/src/flows/ContractorOnboarding/utils.ts index db34d987b..062e3c066 100644 --- a/src/flows/ContractorOnboarding/utils.ts +++ b/src/flows/ContractorOnboarding/utils.ts @@ -1,11 +1,7 @@ import { Step } from '@/src/flows/useStepState'; import { - contractorStandardProductIdentifier, - contractorPlusProductIdentifier, ProductType, PRODUCT_IDENTIFIER_MAP, - REMOTE_AI_SERVICES_AND_DELIVERABLES_ERROR_MESSAGE, - REMOTE_AI_SERVICES_AND_DELIVERABLES_COR_ERROR_MESSAGE, } from '@/src/flows/ContractorOnboarding/constants'; import { Employment } from '@/src/flows/Onboarding/types'; @@ -115,34 +111,6 @@ export function buildSteps(config: StepConfig = {}) { * Calculates the description for the provisional start date field * based on whether the dates match between basic information and contract details steps */ -export const calculateProvisionalStartDateDescription = ( - employmentProvisionalStartDate: string | undefined, - fieldProvisionalStartDate: string | undefined, -): string | undefined => { - const datesNotMatching = - employmentProvisionalStartDate && - fieldProvisionalStartDate && - employmentProvisionalStartDate !== fieldProvisionalStartDate; - - if (datesNotMatching) { - const datesDontMatchWarning = `This date does not match the date you provided in the Basic Information step - ${ - employmentProvisionalStartDate - } - and will override it only when both parties have signed the contract.`; - return `When the contractor will start providing service to your company. ${datesDontMatchWarning}`; - } - return undefined; -}; - -/** - * Checks if the selected pricing plan is CM (standard) or CM+ (plus) - */ -export const isCMOrCMPlus = (subscription: string | undefined): boolean => { - return ( - subscription === contractorStandardProductIdentifier || - subscription === contractorPlusProductIdentifier - ); -}; - const NATIONALITY_COUNTRY_CODES = ['SAU', 'KWT', 'OMN', 'QAT', 'BHR']; /** @@ -198,15 +166,6 @@ export const disabledInviteButtonEmploymentStatus: Employment['status'][] = [ * @param errors - Array of error messages from the API * @returns Array containing the appropriate default error message */ -export function transformAiErrorResponse( - isContractorOfRecord: boolean, -): string { - const remoteAiErrorMessage = isContractorOfRecord - ? REMOTE_AI_SERVICES_AND_DELIVERABLES_COR_ERROR_MESSAGE - : REMOTE_AI_SERVICES_AND_DELIVERABLES_ERROR_MESSAGE; - return remoteAiErrorMessage; -} - const DEFAULT_VERSION = 1; /** From 244968e535dfe4b7c9bf41441106b8dd84fa25ad Mon Sep 17 00:00:00 2001 From: Jordi Vidaller Date: Thu, 17 Sep 2026 11:49:49 +0200 Subject: [PATCH 02/17] feat(contract-document): render the contract details and create the contract document MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `contract_details` step now renders the country-specific contract details form and submitting it creates the contract document, then moves the flow to `contract_preview`. The form is built from `GET /v1/countries/{code}/contractor-contract-details?employment_id=…` through the shared `useContractorContractDetailsSchema`, so the currencies come from the contractor and the schema's `default` values prefill the form. Initial values are completed from the employment's current contract details and provisional start date. The standalone flow has no pricing-plan step, so the product — Contractor of Record restrictions, the Contractor Services Agreement disclaimer, the backdating rule — is read off the employment's `contractor_type`. The AI misclassification check behaves as in onboarding: a skippable rejection shows the warning under the field, `canSkipAiValidation` turns true, and submitting again sends `skip_ai_checks: true`. New on the bag: `fieldValues`, `checkFieldUpdates`, `handleValidation`, `parseFormValues`, `onSubmit`, `initialValues`, `meta`, `productIdentifier`, `contractDocumentId`, `canSkipAiValidation`. New components: `ContractDocumentForm`, `ContractDocumentSubmitButton`. `options.jsfModify.contract_details` is supported. Part of pbyr-4778. Co-Authored-By: Claude Fable 5.1 --- example/src/ContractDocument.tsx | 103 +++++-- .../ContractDocument/ContractDocumentFlow.tsx | 3 +- .../ContractDocument/ContractDocumentForm.tsx | 101 +++++++ .../ContractDocumentSubmitButton.tsx | 37 +++ src/flows/ContractDocument/README.md | 140 +++++++--- src/flows/ContractDocument/hooks.tsx | 261 +++++++++++++++++- src/flows/ContractDocument/index.ts | 6 + .../tests/ContractDocumentForm.test.tsx | 148 ++++++++++ src/flows/ContractDocument/types.ts | 31 +++ src/flows/ContractDocument/utils.ts | 26 ++ src/index.tsx | 6 + 11 files changed, 794 insertions(+), 68 deletions(-) create mode 100644 src/flows/ContractDocument/ContractDocumentForm.tsx create mode 100644 src/flows/ContractDocument/ContractDocumentSubmitButton.tsx create mode 100644 src/flows/ContractDocument/tests/ContractDocumentForm.test.tsx diff --git a/example/src/ContractDocument.tsx b/example/src/ContractDocument.tsx index f1c449eaa..b923af1a3 100644 --- a/example/src/ContractDocument.tsx +++ b/example/src/ContractDocument.tsx @@ -1,28 +1,83 @@ -import { ContractDocumentFlow } from '@remoteoss/remote-flows'; +import { + ContractDocumentFlow, + ContractDocumentForm, + ContractDocumentSubmitButton, + NormalizedFieldError, +} from '@remoteoss/remote-flows'; import { useState } from 'react'; +import { AlertError } from './AlertError'; import { EmploymentIdForm } from './components/EmploymentIdForm'; import { RemoteFlows } from './RemoteFlows'; import './css/main.css'; function CreateContractDocument({ employmentId }: { employmentId: string }) { + const [errors, setErrors] = useState<{ + apiError: string; + fieldErrors: NormalizedFieldError[]; + }>({ apiError: '', fieldErrors: [] }); + return ( { if (contractDocumentBag.isLoading) { - return
Loading…
; + return
Loading contract document...
; } + const { steps, stepState } = contractDocumentBag; + return ( -
-

Create contract document

-

- {contractDocumentBag.employment?.full_name} - {contractDocumentBag.isContractorOfRecord && - ' (Contractor of Record)'} -

-

To be continued…

-
+ <> +
+
    + {steps.map((step) => ( +
  • + {step.index + 1}. {step.label} +
  • + ))} +
+
+
+

+ {stepState.currentStep.name === 'contract_details' + ? 'Contract Details' + : 'Contract Preview'} +

+ {stepState.currentStep.name === 'contract_details' ? ( + <> + + setErrors({ apiError: error.message, fieldErrors }) + } + /> + + {contractDocumentBag.canSkipAiValidation && ( +

+ AI validation detected potential compliance issues. Edit + the Services and Deliverables field above, or continue at + your own risk by submitting again. +

+ )} + setErrors({ apiError: '', fieldErrors: [] })} + > + {contractDocumentBag.canSkipAiValidation + ? 'Continue anyway' + : 'Next Step'} + + + ) : ( +

+ Contract document {contractDocumentBag.contractDocumentId}. To + be continued… +

+ )} +
+ ); }} /> @@ -32,19 +87,19 @@ function CreateContractDocument({ employmentId }: { employmentId: string }) { export function ContractDocument() { const [employmentId, setEmploymentId] = useState(null); + if (employmentId) { + return ( + + + + ); + } + return ( - -
- {employmentId ? ( - - ) : ( - - )} -
-
+ ); } diff --git a/src/flows/ContractDocument/ContractDocumentFlow.tsx b/src/flows/ContractDocument/ContractDocumentFlow.tsx index 39820dedf..11ed34161 100644 --- a/src/flows/ContractDocument/ContractDocumentFlow.tsx +++ b/src/flows/ContractDocument/ContractDocumentFlow.tsx @@ -9,10 +9,11 @@ import { ContractDocumentFlowProps } from '@/src/flows/ContractDocument/types'; */ export const ContractDocumentFlow = ({ employmentId, + options, render, }: ContractDocumentFlowProps) => { const formId = useId(); - const contractDocumentBag = useContractDocument({ employmentId }); + const contractDocumentBag = useContractDocument({ employmentId, options }); return ( diff --git a/src/flows/ContractDocument/ContractDocumentForm.tsx b/src/flows/ContractDocument/ContractDocumentForm.tsx new file mode 100644 index 000000000..42e33d60e --- /dev/null +++ b/src/flows/ContractDocument/ContractDocumentForm.tsx @@ -0,0 +1,101 @@ +import { useEffect } from 'react'; +import { FieldValues } from 'react-hook-form'; +import { StatementOfWorkDisclaimer } from '@/src/common/contract-documents/components/StatementOfWorkDisclaimer'; +import { isCMOrCMPlus } from '@/src/common/contract-documents/utils'; +import { JSONSchemaFormFields } from '@/src/components/form/JSONSchemaForm'; +import { useJSONSchemaForm } from '@/src/components/form/useJSONSchemaForm'; +import { Form } from '@/src/components/ui/form'; +import { useContractDocumentContext } from '@/src/flows/ContractDocument/context'; +import { + ContractDocumentContractDetailsPayload, + ContractDocumentContractDetailsResponse, +} from '@/src/flows/ContractDocument/types'; +import { NormalizedFieldError } from '@/src/lib/mutations'; +import { handleStepError } from '@/src/lib/utils'; +import { Components } from '@/src/types/remoteFlows'; + +type ContractDocumentFormProps = { + /** + * Components to override the default field components used in the form. + */ + components?: Components; + /** + * Called with the parsed payload before it is sent to Remote. Throwing aborts the submission. + */ + onSubmit?: ( + payload: ContractDocumentContractDetailsPayload, + ) => Promise | void; + /** + * Called once the contract document has been created, before moving to the preview step. + */ + onSuccess?: ( + data: ContractDocumentContractDetailsResponse, + ) => Promise | void; + /** + * Called when creating the contract document fails. + */ + onError?: (error: { + error: Error; + rawError: Record; + fieldErrors: NormalizedFieldError[]; + }) => void; +}; + +export function ContractDocumentForm({ + components, + onSubmit, + onSuccess, + onError, +}: ContractDocumentFormProps) { + const { formId, contractDocumentBag } = useContractDocumentContext(); + + const form = useJSONSchemaForm({ + handleValidation: contractDocumentBag.handleValidation, + checkFieldUpdates: contractDocumentBag.checkFieldUpdates, + defaultValues: contractDocumentBag.initialValues.contract_details, + }); + + useEffect(() => { + contractDocumentBag.checkFieldUpdates(form.getValues()); + // oxlint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const handleSubmit = async (values: FieldValues) => { + try { + const payload = await contractDocumentBag.parseFormValues(values); + await onSubmit?.(payload); + + const response = await contractDocumentBag.onSubmit(values); + if (response?.data) { + await onSuccess?.(response.data); + } + contractDocumentBag.next(); + } catch (error: unknown) { + onError?.(handleStepError(error, undefined, form)); + } + }; + + return ( +
+
+ + + + + {isCMOrCMPlus(contractDocumentBag.productIdentifier) && ( + + )} +
+ ); +} diff --git a/src/flows/ContractDocument/ContractDocumentSubmitButton.tsx b/src/flows/ContractDocument/ContractDocumentSubmitButton.tsx new file mode 100644 index 000000000..9c97cf5dc --- /dev/null +++ b/src/flows/ContractDocument/ContractDocumentSubmitButton.tsx @@ -0,0 +1,37 @@ +import { ButtonHTMLAttributes, PropsWithChildren } from 'react'; +import { useContractDocumentContext } from '@/src/flows/ContractDocument/context'; +import { useFormFields } from '@/src/context'; +import { cn } from '@/src/lib/utils'; + +export function ContractDocumentSubmitButton({ + children, + ...props +}: PropsWithChildren> & + Record) { + const { formId, contractDocumentBag } = useContractDocumentContext(); + const { components } = useFormFields(); + + const CustomButton = components?.button; + if (!CustomButton) { + throw new Error(`Button component not found`); + } + + return ( + + {children} + + ); +} diff --git a/src/flows/ContractDocument/README.md b/src/flows/ContractDocument/README.md index d4f0c3cb9..6b70246e4 100644 --- a/src/flows/ContractDocument/README.md +++ b/src/flows/ContractDocument/README.md @@ -12,9 +12,9 @@ source that id — a picker of your own, a route param, the row the user clicked If you want these screens **inside** contractor onboarding, that is part of the [Contractor Onboarding](../ContractorOnboarding/README.md) flow instead. -> **Work in progress.** This release loads the contractor and their existing contract documents -> and exposes them on the bag, but no form is rendered yet. Until the flow is complete, its props -> and bag may change between minor versions without a major bump. +> **Work in progress.** This release renders the contract details form and creates the +> contract document. The contract preview step is reached but renders nothing yet. Until the +> flow is complete, its props and bag may change between minor versions without a major bump. # Table of Contents @@ -22,7 +22,11 @@ If you want these screens **inside** contractor onboarding, that is part of the - [Full Example](#full-example) - [Components API](#components-api) - [ContractDocumentFlow](#contractdocumentflow) + - [ContractDocumentForm](#contractdocumentform) + - [ContractDocumentSubmitButton](#contractdocumentsubmitbutton) - [The bag](#the-bag) +- [Requests](#requests) +- [AI misclassification check](#ai-misclassification-check) ## Getting Started @@ -35,7 +39,12 @@ After installation, import the main CSS file in your application: ### Full Example ```tsx -import { ContractDocumentFlow, RemoteFlows } from '@remoteoss/remote-flows'; +import { + ContractDocumentFlow, + ContractDocumentForm, + ContractDocumentSubmitButton, + RemoteFlows, +} from '@remoteoss/remote-flows'; export function CreateContractDocument({ employmentId, @@ -51,7 +60,27 @@ export function CreateContractDocument({ return
Loading…
; } - return

{contractDocumentBag.employment?.full_name}

; + if ( + contractDocumentBag.stepState.currentStep.name === + 'contract_preview' + ) { + return ( +

Contract document {contractDocumentBag.contractDocumentId}

+ ); + } + + return ( + <> + console.error(error)} + /> + + {contractDocumentBag.canSkipAiValidation + ? 'Continue anyway' + : 'Continue'} + + + ); }} /> @@ -63,37 +92,86 @@ export function CreateContractDocument({ ### ContractDocumentFlow -| Prop | Type | Required | Description | -| -------------- | -------------------- | -------- | ---------------------------------------------------- | -| `employmentId` | `string` | Yes | The contractor the contract document is created for. | -| `render` | `(bag) => ReactNode` | Yes | Render prop receiving the flow bag. | +| Prop | Type | Required | Description | +| -------------- | ------------------------------------------ | -------- | ---------------------------------------------------- | +| `employmentId` | `string` | Yes | The contractor the contract document is created for. | +| `render` | `(bag) => ReactNode` | Yes | Render prop receiving the flow bag. | +| `options` | `{ jsfModify?: { contract_details?: … } }` | No | Modify the generated JSON-schema fields, per step. | + +### ContractDocumentForm + +Renders the contract details form on the `contract_details` step. Submitting it creates the +contract document and moves the flow to `contract_preview`. + +| Prop | Type | Description | +| ------------ | -------------------------------------------- | ------------------------------------------------------------------------------ | +| `components` | `Components` | Override the field components used in the form. | +| `onSubmit` | `(payload) => void \| Promise` | Receives the parsed payload before it is sent. Throwing aborts the submission. | +| `onSuccess` | `(data) => void \| Promise` | Called once the contract document has been created. | +| `onError` | `({ error, rawError, fieldErrors }) => void` | Called when creation fails. Field errors are also set on the form. | + +### ContractDocumentSubmitButton + +Must be rendered inside the flow's `render` prop. Accepts any button props; disables itself +while the contract document is being created, and while the flow has no contractor to act on. ## The bag -`useContractDocument({ employmentId })` is the headless equivalent of the render prop, for -fully custom UIs. Both surfaces expose the same bag: - -| Key | Description | -| ---------------------- | --------------------------------------------------------------------------------------------------------- | -| `stepState` | Current step (`contract_details` or `contract_preview`) and step count. | -| `steps` | Every step of the flow, in order, with its label. | -| `next` | Moves to the next step. | -| `back` | Moves to the previous step. | -| `goTo` | Moves to a specific step. | -| `fields` | Form fields for the current step. Empty for now. | -| `employmentId` | The contractor the contract document will be created for. | -| `employment` | The contractor's employment. | -| `isContractorOfRecord` | Whether the contractor is a Contractor of Record. | -| `contractDocuments` | The contract documents the contractor already has. `undefined` until loaded, or when loading failed. | -| `isLoading` | True until the contractor is known: no `employmentId`, or the employment and its documents still loading. | -| `isSubmitting` | True while a submission is in flight. Always `false` for now. | +`useContractDocument({ employmentId, options })` is the headless equivalent of the render +prop, for fully custom UIs. Both surfaces expose the same bag: + +| Key | Description | +| ---------------------- | ------------------------------------------------------------------------------------------------------------- | +| `stepState` | Current step (`contract_details` or `contract_preview`) and step count. | +| `steps` | Every step of the flow, in order, with its label. | +| `next` | Moves to the next step. | +| `back` | Moves to the previous step. | +| `goTo` | Moves to a specific step. | +| `fields` | Generated form fields for the current step. | +| `fieldValues` | Current values of the form fields for the current step. | +| `checkFieldUpdates` | Feed the latest form values back in so conditional fields re-evaluate. | +| `handleValidation` | Validation handler for the current step's form. | +| `parseFormValues` | Turns the contract details values into the API payload without submitting. | +| `onSubmit` | Creates the contract document. | +| `initialValues` | Initial form values per step, prefilled from the employment and the schema defaults. | +| `meta` | Fieldset metadata for the current step. | +| `employmentId` | The contractor the contract document will be created for. | +| `employment` | The contractor's employment. | +| `isContractorOfRecord` | Whether the contractor is a Contractor of Record. | +| `productIdentifier` | The product the contractor is on, read off the employment. | +| `contractDocuments` | The contract documents the contractor already has. `undefined` until loaded, or when loading failed. | +| `contractDocumentId` | The contract document created in this flow, once there is one. | +| `canSkipAiValidation` | True when the last submission was rejected by the AI check and submitting again continues at the user's risk. | +| `isLoading` | True until the contractor and the current step's form are known. | +| `isSubmitting` | True while the contract document is being created. | ## Requests -| Request | Purpose | -| -------------------------------------------------------- | ----------------------------------------------------- | -| `GET /v1/employments/{employment_id}` | The contractor, to tell a Contractor of Record apart. | -| `GET /v1/employments/{employment_id}/contract-documents` | The contract documents the contractor already has. | +| Request | Purpose | +| ------------------------------------------------------------------------------ | ------------------------------------------------------------------------------- | +| `GET /v1/employments/{employment_id}` | The contractor: country, product, current contract details. | +| `GET /v1/employments/{employment_id}/contract-documents` | The contract documents the contractor already has. | +| `GET /v1/countries/{country_code}/contractor-contract-details?employment_id=…` | The contract details schema. Its `default` values prefill the form (see below). | +| `GET /v1/contractors/employments/{employment_id}/contractor-currencies` | The currencies offered for the compensation. | +| `POST /v1/contractors/employments/{employment_id}/contract-documents` | Creates the contract document from the parsed form values. | -Both are scoped to an employment, so nothing is requested while `employmentId` is empty and +All are scoped to an employment, so nothing is requested while `employmentId` is empty and `isLoading` stays `true`. + +**Prefill.** When `employment_id` refers to a contractor, the schema's fields carry `default` +values sourced from their current contract — payment terms from the stored contract details or, +absent those, the contractor's rate. Those defaults are compensation data and require one of +`documents-management:create`, `people-contracts:create` or `hiring:update`; without them the +schema comes back without `default` values and the form renders blank rather than failing. + +**Product.** The standalone flow has no pricing-plan step. The product is read off the +employment's `contractor_type`: it decides whether the Contractor Services Agreement disclaimer +is shown, whether the start date can be backdated, and which misclassification wording is used. + +## AI misclassification check + +Remote checks the services and deliverables for misclassification risk when the contract +document is created. When the check rejects the text with a skippable error, the form shows a +warning under the field and `canSkipAiValidation` turns `true`; submitting again sends +`skip_ai_checks: true` and continues at the user's own risk. Editing the field clears the +warning and the next submission is checked again. diff --git a/src/flows/ContractDocument/hooks.tsx b/src/flows/ContractDocument/hooks.tsx index 45d7f1762..650182fab 100644 --- a/src/flows/ContractDocument/hooks.tsx +++ b/src/flows/ContractDocument/hooks.tsx @@ -1,15 +1,42 @@ +import { useCallback, useMemo, useState } from 'react'; import { useQuery } from '@tanstack/react-query'; -import { Fields } from '@remoteoss/remote-json-schema-form-kit'; +import { FieldValues } from 'react-hook-form'; +import { + Fields, + ValidationResult, +} from '@remoteoss/remote-json-schema-form-kit'; import { Client } from '@/src/client/client'; import { useEmploymentQuery } from '@/src/common/api/employment'; -import { contractDocumentsOptions } from '@/src/common/contract-documents/api'; +import { useContractorContractDetailsSchema } from '@/src/common/api/contractor-contract-details'; +import { + contractDocumentsOptions, + useCreateContractorContractDocument, +} from '@/src/common/contract-documents/api'; +import { corProductIdentifier } from '@/src/common/contract-documents/constants'; +import { buildContractDetailsJsfModify } from '@/src/common/contract-documents/jsfModify'; +import { + calculateProvisionalStartDateDescription, + extractAiValidationError, + transformAiErrorResponse, +} from '@/src/common/contract-documents/utils'; +import { + getInitialValues, + parseJSFToValidate, +} from '@/src/components/form/utils'; import { useClient } from '@/src/context'; import { + ContractDocumentContractDetailsPayload, ContractDocumentStepKeys, UseContractDocumentOptions, } from '@/src/flows/ContractDocument/types'; -import { STEPS, STEPS_ARRAY } from '@/src/flows/ContractDocument/utils'; +import { + getProductIdentifier, + STEPS, + STEPS_ARRAY, +} from '@/src/flows/ContractDocument/utils'; import { useStepState } from '@/src/flows/useStepState'; +import { mutationToPromise } from '@/src/lib/mutations'; +import { createStructuredError } from '@/src/lib/utils'; /** * Headless hook powering the standalone contract-document flow: the contract details and @@ -18,10 +45,20 @@ import { useStepState } from '@/src/flows/useStepState'; */ export const useContractDocument = ({ employmentId, + options, }: UseContractDocumentOptions) => { const { client } = useClient(); - const { stepState, nextStep, previousStep, goToStep } = - useStepState(STEPS); + const { + fieldValues, + setFieldValues, + stepState, + nextStep, + previousStep, + goToStep, + } = useStepState(STEPS); + const [contractDocumentId, setContractDocumentId] = useState< + string | undefined + >(undefined); const { data: employment, isLoading: isLoadingEmployment } = useEmploymentQuery({ @@ -37,6 +74,155 @@ export const useContractDocument = ({ select: ({ data }) => data.data.contract_documents, }); + const countryCode = employment?.country?.code; + const productIdentifier = getProductIdentifier(employment?.contractor_type); + const isContractorOfRecord = productIdentifier === corProductIdentifier; + + const provisionalStartDateDescription = + calculateProvisionalStartDateDescription( + employment?.basic_information?.provisional_start_date as + | string + | undefined, + fieldValues?.service_duration?.provisional_start_date, + ); + + const { data: contractDetailsForm, isLoading: isLoadingContractDetailsForm } = + useContractorContractDetailsSchema({ + countryCode: countryCode as string, + employmentId, + fieldValues, + options: { + queryOptions: { enabled: Boolean(countryCode) }, + jsfModify: buildContractDetailsJsfModify( + options?.jsfModify?.contract_details, + provisionalStartDateDescription, + productIdentifier, + fieldValues, + isContractorOfRecord, + ), + }, + }); + + const contractDetailsFields = useMemo( + () => (contractDetailsForm?.fields ?? []) as Fields, + [contractDetailsForm?.fields], + ); + + const contractDetailsInitialValues = useMemo( + () => + getInitialValues(contractDetailsFields, { + service_duration: { + provisional_start_date: + employment?.basic_information?.provisional_start_date, + }, + ...employment?.contract_details, + }), + [ + contractDetailsFields, + employment?.basic_information?.provisional_start_date, + employment?.contract_details, + ], + ); + + const createContractDocumentMutation = useCreateContractorContractDocument(); + const { mutateAsyncOrThrow: createContractDocument } = mutationToPromise( + createContractDocumentMutation, + ); + + const checkFieldUpdates = useCallback( + (values: FieldValues) => { + setFieldValues(values); + }, + [setFieldValues], + ); + + const handleValidation = useCallback( + async (values: FieldValues): Promise => { + if (!contractDetailsForm) return null; + + const parsed = await parseJSFToValidate( + values, + contractDetailsForm.fields, + { isPartialValidation: false }, + ); + + return contractDetailsForm.handleValidation(parsed) ?? null; + }, + [contractDetailsForm], + ); + + const parseFormValues = useCallback( + async ( + values: FieldValues, + ): Promise => { + const { + services_and_deliverables_ai_warning: _aiWarning, + services_and_deliverables_error_skippable: _errorSkippable, + ...contractDetails + } = await parseJSFToValidate(values, contractDetailsFields, { + isPartialValidation: false, + }); + + return { + contract_document: contractDetails, + skip_ai_checks: + fieldValues.services_and_deliverables_error_skippable === true, + }; + }, + [ + contractDetailsFields, + fieldValues.services_and_deliverables_error_skippable, + ], + ); + + const onSubmit = useCallback( + async (values: FieldValues) => { + if (!employmentId) { + throw createStructuredError( + 'No contractor to act on. Pass a non-empty `employmentId` to the flow.', + ); + } + + const payload = await parseFormValues(values); + + try { + const response = await createContractDocument({ + employmentId, + payload, + }); + const createdId = response?.data?.contract_document?.id; + if (!createdId) { + throw createStructuredError('Contract document ID not found'); + } + setContractDocumentId(createdId); + + return response; + } catch (error) { + const aiError = extractAiValidationError(error); + if (aiError) { + setFieldValues({ + ...values, + services_and_deliverables_ai_warning: + transformAiErrorResponse(isContractorOfRecord), + services_and_deliverables_error_skippable: aiError.skippable, + }); + } + + throw error; + } + }, + [ + employmentId, + parseFormValues, + createContractDocument, + setFieldValues, + isContractorOfRecord, + ], + ); + + const isContractDetailsStep = + stepState.currentStep.name === 'contract_details'; + return { /** * Current step state containing the current step and total number of steps. @@ -61,7 +247,41 @@ export const useContractDocument = ({ /** * Form fields for the current step. */ - fields: [] as Fields, + fields: isContractDetailsStep ? contractDetailsFields : ([] as Fields), + /** + * Current values of the form fields for the current step. + */ + fieldValues, + /** + * Feed the latest form values back in so conditional fields re-evaluate. + */ + checkFieldUpdates, + /** + * Validation handler for the current step's form. + */ + handleValidation, + /** + * Turns the contract details form values into the API payload without submitting. + */ + parseFormValues, + /** + * Creates the contract document from the contract details form values. + */ + onSubmit, + /** + * Initial values per step. + */ + initialValues: { + contract_details: contractDetailsInitialValues, + }, + /** + * Field metadata per step, for building error messages and rendering fieldsets. + */ + meta: { + fieldsets: isContractDetailsStep + ? contractDetailsForm?.meta?.['x-jsf-fieldsets'] + : undefined, + }, /** * The contractor the contract document will be created for. */ @@ -73,21 +293,38 @@ export const useContractDocument = ({ /** * Whether the contractor is a Contractor of Record. */ - isContractorOfRecord: employment?.contractor_type === 'cor', + isContractorOfRecord, + /** + * The product the contractor is on, read off the employment. + */ + productIdentifier, /** * The contract documents the contractor already has. `undefined` until they have * loaded, or when loading them failed. */ contractDocuments, /** - * True until the contractor is known: `employmentId` is empty, or the employment and - * its contract documents are still loading. + * The contract document created in this flow, once there is one. + */ + contractDocumentId, + /** + * True when the last submission was rejected by the AI misclassification check and the + * user may submit again to continue at their own risk. + */ + canSkipAiValidation: + fieldValues.services_and_deliverables_error_skippable === true, + /** + * True until the contractor and the current step's form are known: `employmentId` is + * empty, or the employment, its contract documents or the schema are still loading. */ isLoading: - !employmentId || isLoadingEmployment || isLoadingContractDocuments, + !employmentId || + isLoadingEmployment || + isLoadingContractDocuments || + (isContractDetailsStep && isLoadingContractDetailsForm), /** - * True while a submission is in flight. + * True while the contract document is being created. */ - isSubmitting: false, + isSubmitting: createContractDocumentMutation.isPending, }; }; diff --git a/src/flows/ContractDocument/index.ts b/src/flows/ContractDocument/index.ts index 4008b30c7..f8c30a1b7 100644 --- a/src/flows/ContractDocument/index.ts +++ b/src/flows/ContractDocument/index.ts @@ -1,7 +1,13 @@ export { ContractDocumentFlow } from './ContractDocumentFlow'; +export { ContractDocumentForm } from './ContractDocumentForm'; +export { ContractDocumentSubmitButton } from './ContractDocumentSubmitButton'; export { useContractDocument } from './hooks'; export type { + ContractDocumentContractDetailsFormValues, + ContractDocumentContractDetailsPayload, + ContractDocumentContractDetailsResponse, ContractDocumentFlowProps, + ContractDocumentOptions, ContractDocumentStepKeys, UseContractDocumentOptions, } from './types'; diff --git a/src/flows/ContractDocument/tests/ContractDocumentForm.test.tsx b/src/flows/ContractDocument/tests/ContractDocumentForm.test.tsx new file mode 100644 index 000000000..7406f5fbf --- /dev/null +++ b/src/flows/ContractDocument/tests/ContractDocumentForm.test.tsx @@ -0,0 +1,148 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { http, HttpResponse } from 'msw'; +import { server } from '@/src/tests/server'; +import { queryClient, TestProviders } from '@/src/tests/testHelpers'; +import { ContractDocumentFlow } from '@/src/flows/ContractDocument/ContractDocumentFlow'; +import { ContractDocumentForm } from '@/src/flows/ContractDocument/ContractDocumentForm'; +import { ContractDocumentSubmitButton } from '@/src/flows/ContractDocument/ContractDocumentSubmitButton'; +import { + mockContractDocumentCreatedResponse, + mockContractorContractDetailsSchema, + mockContractorEmploymentResponse, +} from '@/src/flows/ContractorOnboarding/tests/fixtures'; +import { fillContractDetails } from '@/src/flows/ContractorOnboarding/tests/helpers'; +import { $TSFixMe } from '@/src/types/remoteFlows'; + +const aiValidationErrorResponse = { + errors: { + services_and_deliverables: [ + { + error: ['Possible misclassification risk'], + source: 'REMOTE_AI', + skippable: true, + }, + ], + }, + message: 'Unprocessable Entity', +}; + +function renderFlow() { + return render( + { + if (bag.isLoading) return

Loading…

; + + if (bag.stepState.currentStep.name === 'contract_preview') { + return

Preview of {bag.contractDocumentId}

; + } + + return ( + <> + + + {bag.canSkipAiValidation ? 'Continue anyway' : 'Continue'} + + + ); + }} + />, + { wrapper: TestProviders }, + ); +} + +describe('ContractDocumentForm', () => { + const createdDocuments: $TSFixMe[] = []; + + beforeEach(() => { + queryClient.clear(); + createdDocuments.length = 0; + + server.use( + http.get('*/v1/employments/:id', ({ params }) => + HttpResponse.json({ + ...mockContractorEmploymentResponse, + data: { + ...mockContractorEmploymentResponse.data, + employment: { + ...mockContractorEmploymentResponse.data.employment, + id: params.id, + }, + }, + }), + ), + http.get('*/v1/countries/*/contractor-contract-details*', () => + HttpResponse.json(mockContractorContractDetailsSchema), + ), + ); + }); + + it('creates the contract document from the contract details and moves to the preview', async () => { + server.use( + http.post( + '*/v1/contractors/employments/employment-grace/contract-documents', + async ({ request }) => { + createdDocuments.push(await request.json()); + return HttpResponse.json(mockContractDocumentCreatedResponse); + }, + ), + ); + + renderFlow(); + await fillContractDetails(); + + fireEvent.click(screen.getByRole('button', { name: 'Continue' })); + + expect( + await screen.findByText( + `Preview of ${mockContractDocumentCreatedResponse.data.contract_document.id}`, + ), + ).toBeInTheDocument(); + expect(createdDocuments).toHaveLength(1); + expect(createdDocuments[0].skip_ai_checks).toBe(false); + expect( + createdDocuments[0].contract_document.services_and_deliverables, + ).toBe('Service and Deliverables project manager role'); + }); + + it('shows the AI misclassification warning and retries with skip_ai_checks', async () => { + server.use( + http.post( + '*/v1/contractors/employments/employment-grace/contract-documents', + async ({ request }) => { + createdDocuments.push(await request.json()); + if (createdDocuments.length === 1) { + return HttpResponse.json(aiValidationErrorResponse, { + status: 422, + }); + } + return HttpResponse.json(mockContractDocumentCreatedResponse); + }, + ), + ); + + renderFlow(); + await fillContractDetails(); + + fireEvent.click(screen.getByRole('button', { name: 'Continue' })); + + expect( + await screen.findByText(/Possible misclassification risk/i), + ).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: 'Continue anyway' })); + + await waitFor(() => { + expect(createdDocuments).toHaveLength(2); + }); + expect(createdDocuments[1].skip_ai_checks).toBe(true); + expect(createdDocuments[1].contract_document).not.toHaveProperty( + 'services_and_deliverables_ai_warning', + ); + expect( + await screen.findByText( + `Preview of ${mockContractDocumentCreatedResponse.data.contract_document.id}`, + ), + ).toBeInTheDocument(); + }); +}); diff --git a/src/flows/ContractDocument/types.ts b/src/flows/ContractDocument/types.ts index 0b5bb68d3..d60002a1f 100644 --- a/src/flows/ContractDocument/types.ts +++ b/src/flows/ContractDocument/types.ts @@ -1,13 +1,26 @@ import React from 'react'; +import { CreateContractDocument } from '@/src/client/types.gen'; +import { + ContractorContractDetailsFormPayload, + ContractorContractDetailsResponse, +} from '@/src/common/contract-documents/types'; import { useContractDocument } from '@/src/flows/ContractDocument/hooks'; +import { JSFModify } from '@/src/flows/types'; export type ContractDocumentStepKeys = 'contract_details' | 'contract_preview'; +export type ContractDocumentOptions = { + jsfModify?: { + contract_details?: JSFModify; + }; +}; + export type UseContractDocumentOptions = { /** * The contractor to create the contract document for. */ employmentId: string; + options?: ContractDocumentOptions; }; export type ContractDocumentFlowProps = { @@ -16,7 +29,25 @@ export type ContractDocumentFlowProps = { * param, the row the user clicked; the flow only ever acts on this one contractor. */ employmentId: string; + /** + * Modify the generated JSON-schema form fields, per step. + */ + options?: ContractDocumentOptions; render: ( contractDocumentBag: ReturnType, ) => React.ReactNode; }; + +/** + * Values the contract details form collects. + */ +export type ContractDocumentContractDetailsFormValues = + ContractorContractDetailsFormPayload; + +/** + * The payload sent to `POST /v1/contractors/employments/{employment_id}/contract-documents`. + */ +export type ContractDocumentContractDetailsPayload = CreateContractDocument; + +export type ContractDocumentContractDetailsResponse = + ContractorContractDetailsResponse['data']; diff --git a/src/flows/ContractDocument/utils.ts b/src/flows/ContractDocument/utils.ts index 1a7818401..d3160d1ac 100644 --- a/src/flows/ContractDocument/utils.ts +++ b/src/flows/ContractDocument/utils.ts @@ -1,3 +1,9 @@ +import { Employment } from '@/src/client/types.gen'; +import { + contractorPlusProductIdentifier, + contractorStandardProductIdentifier, + corProductIdentifier, +} from '@/src/common/contract-documents/constants'; import { Step } from '@/src/flows/useStepState'; import { ContractDocumentStepKeys } from '@/src/flows/ContractDocument/types'; @@ -18,3 +24,23 @@ export const STEPS_ARRAY = Object.values(STEPS).map((step) => ({ ...step, label: STEP_LABELS[step.name], })); + +const PRODUCT_IDENTIFIER_BY_CONTRACTOR_TYPE: Record< + NonNullable, + string +> = { + standard: contractorStandardProductIdentifier, + plus: contractorPlusProductIdentifier, + cor: corProductIdentifier, +}; + +/** + * The product the contractor is on. The standalone flow has no pricing-plan step, so it is + * read off the employment instead. + */ +export const getProductIdentifier = ( + contractorType: Employment['contractor_type'], +) => + contractorType + ? PRODUCT_IDENTIFIER_BY_CONTRACTOR_TYPE[contractorType] + : contractorStandardProductIdentifier; diff --git a/src/index.tsx b/src/index.tsx index b12ea2919..d899dd660 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -33,11 +33,17 @@ export type { export { ContractDocumentFlow, + ContractDocumentForm, + ContractDocumentSubmitButton, useContractDocument, } from '@/src/flows/ContractDocument'; export type { + ContractDocumentContractDetailsFormValues, + ContractDocumentContractDetailsPayload, + ContractDocumentContractDetailsResponse, ContractDocumentFlowProps, + ContractDocumentOptions, ContractDocumentStepKeys, UseContractDocumentOptions, } from '@/src/flows/ContractDocument'; From a21c3143ab610b8510a54ecff3f8068038967a95 Mon Sep 17 00:00:00 2001 From: Jordi Vidaller Date: Thu, 17 Sep 2026 12:59:17 +0200 Subject: [PATCH 03/17] fix(contract-document): restore entered contract details on back, reword the start-date warning `ContractDocumentForm` initialised from the employment every time it mounted, so going back to the step after a submission dropped what had just been entered. It now reads the step's stored values first, as the onboarding step does. The start-date mismatch warning referred to the Basic Information step, which the standalone flow does not have: the shared helper takes the label of the compared date, and the flow names the contractor's current start date instead. Onboarding keeps its wording. Co-Authored-By: Claude Fable 5.1 --- src/common/contract-documents/utils.ts | 3 ++- src/flows/ContractDocument/ContractDocumentForm.tsx | 4 +++- src/flows/ContractDocument/hooks.tsx | 1 + 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/common/contract-documents/utils.ts b/src/common/contract-documents/utils.ts index 0ffacf3f8..31975ef7f 100644 --- a/src/common/contract-documents/utils.ts +++ b/src/common/contract-documents/utils.ts @@ -11,6 +11,7 @@ import { isMutationError } from '@/src/lib/mutations'; export const calculateProvisionalStartDateDescription = ( employmentProvisionalStartDate: string | undefined, fieldProvisionalStartDate: string | undefined, + currentDateLabel = 'the date you provided in the Basic Information step', ): string | undefined => { const datesNotMatching = employmentProvisionalStartDate && @@ -18,7 +19,7 @@ export const calculateProvisionalStartDateDescription = ( employmentProvisionalStartDate !== fieldProvisionalStartDate; if (datesNotMatching) { - const datesDontMatchWarning = `This date does not match the date you provided in the Basic Information step - ${ + const datesDontMatchWarning = `This date does not match ${currentDateLabel} - ${ employmentProvisionalStartDate } - and will override it only when both parties have signed the contract.`; return `When the contractor will start providing service to your company. ${datesDontMatchWarning}`; diff --git a/src/flows/ContractDocument/ContractDocumentForm.tsx b/src/flows/ContractDocument/ContractDocumentForm.tsx index 42e33d60e..8476a44f9 100644 --- a/src/flows/ContractDocument/ContractDocumentForm.tsx +++ b/src/flows/ContractDocument/ContractDocumentForm.tsx @@ -52,7 +52,9 @@ export function ContractDocumentForm({ const form = useJSONSchemaForm({ handleValidation: contractDocumentBag.handleValidation, checkFieldUpdates: contractDocumentBag.checkFieldUpdates, - defaultValues: contractDocumentBag.initialValues.contract_details, + defaultValues: + contractDocumentBag.stepState.values?.contract_details ?? + contractDocumentBag.initialValues.contract_details, }); useEffect(() => { diff --git a/src/flows/ContractDocument/hooks.tsx b/src/flows/ContractDocument/hooks.tsx index 650182fab..d94df69be 100644 --- a/src/flows/ContractDocument/hooks.tsx +++ b/src/flows/ContractDocument/hooks.tsx @@ -84,6 +84,7 @@ export const useContractDocument = ({ | string | undefined, fieldValues?.service_duration?.provisional_start_date, + "the contractor's current start date", ); const { data: contractDetailsForm, isLoading: isLoadingContractDetailsForm } = From fd25db12df7f34607bcaca841e583eefd7748b45 Mon Sep 17 00:00:00 2001 From: Jordi Vidaller Date: Thu, 17 Sep 2026 14:35:05 +0200 Subject: [PATCH 04/17] fix(contract-document): attach field labels to submission errors `handleStepError` was called without field metadata, so API field errors surfaced raw paths like `service_duration/expiration_date`. The hook now prettifies the parsed values against the schema fields on submit, as the onboarding hook does, and exposes them as `meta.fields` for the form to hand to `handleStepError`. Co-Authored-By: Claude Fable 5.1 --- .../ContractDocument/ContractDocumentForm.tsx | 2 +- src/flows/ContractDocument/README.md | 2 +- src/flows/ContractDocument/hooks.tsx | 47 +++++++++++++------ 3 files changed, 34 insertions(+), 17 deletions(-) diff --git a/src/flows/ContractDocument/ContractDocumentForm.tsx b/src/flows/ContractDocument/ContractDocumentForm.tsx index 8476a44f9..9dcff7c46 100644 --- a/src/flows/ContractDocument/ContractDocumentForm.tsx +++ b/src/flows/ContractDocument/ContractDocumentForm.tsx @@ -73,7 +73,7 @@ export function ContractDocumentForm({ } contractDocumentBag.next(); } catch (error: unknown) { - onError?.(handleStepError(error, undefined, form)); + onError?.(handleStepError(error, contractDocumentBag.meta.fields, form)); } }; diff --git a/src/flows/ContractDocument/README.md b/src/flows/ContractDocument/README.md index 6b70246e4..47174473f 100644 --- a/src/flows/ContractDocument/README.md +++ b/src/flows/ContractDocument/README.md @@ -134,7 +134,7 @@ prop, for fully custom UIs. Both surfaces expose the same bag: | `parseFormValues` | Turns the contract details values into the API payload without submitting. | | `onSubmit` | Creates the contract document. | | `initialValues` | Initial form values per step, prefilled from the employment and the schema defaults. | -| `meta` | Fieldset metadata for the current step. | +| `meta` | Field metadata for the current step: labels for error messages, and fieldsets. | | `employmentId` | The contractor the contract document will be created for. | | `employment` | The contractor's employment. | | `isContractorOfRecord` | Whether the contractor is a Contractor of Record. | diff --git a/src/flows/ContractDocument/hooks.tsx b/src/flows/ContractDocument/hooks.tsx index d94df69be..9d3185bbd 100644 --- a/src/flows/ContractDocument/hooks.tsx +++ b/src/flows/ContractDocument/hooks.tsx @@ -1,4 +1,4 @@ -import { useCallback, useMemo, useState } from 'react'; +import { useCallback, useMemo, useRef, useState } from 'react'; import { useQuery } from '@tanstack/react-query'; import { FieldValues } from 'react-hook-form'; import { @@ -36,7 +36,8 @@ import { } from '@/src/flows/ContractDocument/utils'; import { useStepState } from '@/src/flows/useStepState'; import { mutationToPromise } from '@/src/lib/mutations'; -import { createStructuredError } from '@/src/lib/utils'; +import { createStructuredError, prettifyFormValues } from '@/src/lib/utils'; +import { NestedMeta } from '@/src/types/remoteFlows'; /** * Headless hook powering the standalone contract-document flow: the contract details and @@ -59,6 +60,7 @@ export const useContractDocument = ({ const [contractDocumentId, setContractDocumentId] = useState< string | undefined >(undefined); + const fieldsMetaRef = useRef({}); const { data: employment, isLoading: isLoadingEmployment } = useEmploymentQuery({ @@ -152,17 +154,21 @@ export const useContractDocument = ({ [contractDetailsForm], ); - const parseFormValues = useCallback( - async ( - values: FieldValues, - ): Promise => { + const parseContractDetails = useCallback( + (values: FieldValues) => + parseJSFToValidate(values, contractDetailsFields, { + isPartialValidation: false, + }), + [contractDetailsFields], + ); + + const buildPayload = useCallback( + (parsedValues: FieldValues): ContractDocumentContractDetailsPayload => { const { services_and_deliverables_ai_warning: _aiWarning, services_and_deliverables_error_skippable: _errorSkippable, ...contractDetails - } = await parseJSFToValidate(values, contractDetailsFields, { - isPartialValidation: false, - }); + } = parsedValues; return { contract_document: contractDetails, @@ -170,10 +176,13 @@ export const useContractDocument = ({ fieldValues.services_and_deliverables_error_skippable === true, }; }, - [ - contractDetailsFields, - fieldValues.services_and_deliverables_error_skippable, - ], + [fieldValues.services_and_deliverables_error_skippable], + ); + + const parseFormValues = useCallback( + async (values: FieldValues) => + buildPayload(await parseContractDetails(values)), + [buildPayload, parseContractDetails], ); const onSubmit = useCallback( @@ -184,7 +193,12 @@ export const useContractDocument = ({ ); } - const payload = await parseFormValues(values); + const parsedValues = await parseContractDetails(values); + fieldsMetaRef.current = prettifyFormValues( + parsedValues, + contractDetailsFields, + ); + const payload = buildPayload(parsedValues); try { const response = await createContractDocument({ @@ -214,7 +228,9 @@ export const useContractDocument = ({ }, [ employmentId, - parseFormValues, + parseContractDetails, + contractDetailsFields, + buildPayload, createContractDocument, setFieldValues, isContractorOfRecord, @@ -279,6 +295,7 @@ export const useContractDocument = ({ * Field metadata per step, for building error messages and rendering fieldsets. */ meta: { + fields: fieldsMetaRef.current, fieldsets: isContractDetailsStep ? contractDetailsForm?.meta?.['x-jsf-fieldsets'] : undefined, From 9041028644ad65f0e1b8bf4f3b01b9f8d7b2eed6 Mon Sep 17 00:00:00 2001 From: Jordi Vidaller Date: Thu, 17 Sep 2026 14:44:11 +0200 Subject: [PATCH 05/17] fix(contract-document): keep field metadata reachable from an in-flight submission The metadata ref was replaced on submit, but the form's handler had already captured the bag rendered before, whose `meta.fields` still pointed at the previous empty object. The ref now holds one object mutated per step, as the onboarding hook does, so the handler reads the labels computed for the submission it is reporting on. Co-Authored-By: Claude Fable 5.1 --- src/flows/ContractDocument/ContractDocumentForm.tsx | 8 +++++++- src/flows/ContractDocument/hooks.tsx | 6 ++++-- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/src/flows/ContractDocument/ContractDocumentForm.tsx b/src/flows/ContractDocument/ContractDocumentForm.tsx index 9dcff7c46..3bb398526 100644 --- a/src/flows/ContractDocument/ContractDocumentForm.tsx +++ b/src/flows/ContractDocument/ContractDocumentForm.tsx @@ -73,7 +73,13 @@ export function ContractDocumentForm({ } contractDocumentBag.next(); } catch (error: unknown) { - onError?.(handleStepError(error, contractDocumentBag.meta.fields, form)); + onError?.( + handleStepError( + error, + contractDocumentBag.meta.fields.contract_details, + form, + ), + ); } }; diff --git a/src/flows/ContractDocument/hooks.tsx b/src/flows/ContractDocument/hooks.tsx index 9d3185bbd..37e7068e6 100644 --- a/src/flows/ContractDocument/hooks.tsx +++ b/src/flows/ContractDocument/hooks.tsx @@ -60,7 +60,9 @@ export const useContractDocument = ({ const [contractDocumentId, setContractDocumentId] = useState< string | undefined >(undefined); - const fieldsMetaRef = useRef({}); + const fieldsMetaRef = useRef<{ contract_details: NestedMeta }>({ + contract_details: {}, + }); const { data: employment, isLoading: isLoadingEmployment } = useEmploymentQuery({ @@ -194,7 +196,7 @@ export const useContractDocument = ({ } const parsedValues = await parseContractDetails(values); - fieldsMetaRef.current = prettifyFormValues( + fieldsMetaRef.current.contract_details = prettifyFormValues( parsedValues, contractDetailsFields, ); From 8beaef6647498bf7ac479f4f3e8345de168a2b61 Mon Sep 17 00:00:00 2001 From: Jordi Vidaller Date: Thu, 17 Sep 2026 15:00:01 +0200 Subject: [PATCH 06/17] chore(contractor-onboarding): drop the doc comments orphaned by the move Co-Authored-By: Claude Fable 5.1 --- src/flows/ContractorOnboarding/utils.ts | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/src/flows/ContractorOnboarding/utils.ts b/src/flows/ContractorOnboarding/utils.ts index 062e3c066..8511c247c 100644 --- a/src/flows/ContractorOnboarding/utils.ts +++ b/src/flows/ContractorOnboarding/utils.ts @@ -107,10 +107,6 @@ export function buildSteps(config: StepConfig = {}) { return { steps, stepsArray }; } -/** - * Calculates the description for the provisional start date field - * based on whether the dates match between basic information and contract details steps - */ const NATIONALITY_COUNTRY_CODES = ['SAU', 'KWT', 'OMN', 'QAT', 'BHR']; /** @@ -160,12 +156,6 @@ export const disabledInviteButtonEmploymentStatus: Employment['status'][] = [ 'active', ]; -/** - * Transforms AI error messages to use default localized messages - * @param isContractorOfRecord - Whether the contractor is a Contractor of Record - * @param errors - Array of error messages from the API - * @returns Array containing the appropriate default error message - */ const DEFAULT_VERSION = 1; /** From f0b021a91ae37652b6478cb113ad7f420cc64f02 Mon Sep 17 00:00:00 2001 From: Jordi Vidaller Date: Thu, 17 Sep 2026 15:00:01 +0200 Subject: [PATCH 07/17] fix(contract-document): fail the contract-details schema query on an error response The shared schema query resolved an error response as a success, so a 403 or 5xx built an empty form and the flow reported itself ready with no fields and a live submit button. The query now throws on `response.error`, as the countries and contract-documents queries do. Tests answer the schema endpoint by default so the standalone flow tests no longer run through an unhandled request, and two behaviours are pinned: entered contract details are restored when coming back from the preview, and a Contractor of Record is not shown the Contractor Services Agreement disclaimer. Co-Authored-By: Claude Fable 5.1 --- src/common/api/contractor-contract-details.ts | 22 ++++-- .../tests/ContractDocumentForm.test.tsx | 78 ++++++++++++++----- src/tests/handlers.ts | 9 +++ 3 files changed, 83 insertions(+), 26 deletions(-) diff --git a/src/common/api/contractor-contract-details.ts b/src/common/api/contractor-contract-details.ts index 7160a6f1e..553dea431 100644 --- a/src/common/api/contractor-contract-details.ts +++ b/src/common/api/contractor-contract-details.ts @@ -67,14 +67,22 @@ const useContractorOnboardingDetailsSchema = ({ ], retry: false, queryFn: async () => { - return getV1CountriesCountryCodeContractorContractDetails({ - client: client as Client, - path: { country_code: countryCode }, - query: { - json_schema_version: 1, - employment_id: employmentId, + const response = await getV1CountriesCountryCodeContractorContractDetails( + { + client: client as Client, + path: { country_code: countryCode }, + query: { + json_schema_version: 1, + employment_id: employmentId, + }, }, - }); + ); + + if (response.error || !response.data) { + throw new Error('Failed to fetch contractor contract details schema'); + } + + return response; }, enabled: options?.queryOptions?.enabled, select: ({ data }) => { diff --git a/src/flows/ContractDocument/tests/ContractDocumentForm.test.tsx b/src/flows/ContractDocument/tests/ContractDocumentForm.test.tsx index 7406f5fbf..929833276 100644 --- a/src/flows/ContractDocument/tests/ContractDocumentForm.test.tsx +++ b/src/flows/ContractDocument/tests/ContractDocumentForm.test.tsx @@ -7,7 +7,6 @@ import { ContractDocumentForm } from '@/src/flows/ContractDocument/ContractDocum import { ContractDocumentSubmitButton } from '@/src/flows/ContractDocument/ContractDocumentSubmitButton'; import { mockContractDocumentCreatedResponse, - mockContractorContractDetailsSchema, mockContractorEmploymentResponse, } from '@/src/flows/ContractorOnboarding/tests/fixtures'; import { fillContractDetails } from '@/src/flows/ContractorOnboarding/tests/helpers'; @@ -34,7 +33,14 @@ function renderFlow() { if (bag.isLoading) return

Loading…

; if (bag.stepState.currentStep.name === 'contract_preview') { - return

Preview of {bag.contractDocumentId}

; + return ( + <> +

Preview of {bag.contractDocumentId}

+ + + ); } return ( @@ -51,6 +57,24 @@ function renderFlow() { ); } +function mockEmployment(contractorType?: 'standard' | 'plus' | 'cor') { + server.use( + http.get('*/v1/employments/:id', ({ params }) => + HttpResponse.json({ + ...mockContractorEmploymentResponse, + data: { + ...mockContractorEmploymentResponse.data, + employment: { + ...mockContractorEmploymentResponse.data.employment, + id: params.id, + contractor_type: contractorType, + }, + }, + }), + ), + ); +} + describe('ContractDocumentForm', () => { const createdDocuments: $TSFixMe[] = []; @@ -58,23 +82,7 @@ describe('ContractDocumentForm', () => { queryClient.clear(); createdDocuments.length = 0; - server.use( - http.get('*/v1/employments/:id', ({ params }) => - HttpResponse.json({ - ...mockContractorEmploymentResponse, - data: { - ...mockContractorEmploymentResponse.data, - employment: { - ...mockContractorEmploymentResponse.data.employment, - id: params.id, - }, - }, - }), - ), - http.get('*/v1/countries/*/contractor-contract-details*', () => - HttpResponse.json(mockContractorContractDetailsSchema), - ), - ); + mockEmployment(); }); it('creates the contract document from the contract details and moves to the preview', async () => { @@ -105,6 +113,38 @@ describe('ContractDocumentForm', () => { ).toBe('Service and Deliverables project manager role'); }); + it('restores the entered contract details when coming back from the preview', async () => { + server.use( + http.post( + '*/v1/contractors/employments/employment-grace/contract-documents', + () => HttpResponse.json(mockContractDocumentCreatedResponse), + ), + ); + + renderFlow(); + await fillContractDetails({ serviceAndDeliverables: 'Design work' }); + + fireEvent.click(screen.getByRole('button', { name: 'Continue' })); + await screen.findByText(/Preview of/); + + fireEvent.click(screen.getByRole('button', { name: 'Back' })); + + expect( + await screen.findByLabelText(/Services and Deliverables/i), + ).toHaveValue('Design work'); + }); + + it('leaves out the Contractor Services Agreement disclaimer for a Contractor of Record', async () => { + mockEmployment('cor'); + + renderFlow(); + await screen.findByLabelText(/Services and Deliverables/i); + + expect( + screen.queryByText('Contractor Services Agreement'), + ).not.toBeInTheDocument(); + }); + it('shows the AI misclassification warning and retries with skip_ai_checks', async () => { server.use( http.post( diff --git a/src/tests/handlers.ts b/src/tests/handlers.ts index c163a7a6a..06109cec1 100644 --- a/src/tests/handlers.ts +++ b/src/tests/handlers.ts @@ -20,6 +20,7 @@ import { } from '@/src/common/api/fixtures/companies'; import { mockBaseResponse } from '@/src/common/api/fixtures/base'; import { mockEmptyContractDocumentsResponse } from '@/src/common/api/fixtures/contract-documents'; +import { mockContractorContractDetailsSchema } from '@/src/flows/ContractorOnboarding/tests/fixtures'; import { mockBasicInformationResponse, mockBenefitOffersResponse, @@ -190,6 +191,13 @@ const employmentHandler = http.get('*/v1/employments/:id', ({ params }) => { }); }); +const contractorContractDetailsSchemaHandler = http.get( + '*/v1/countries/*/contractor-contract-details*', + () => { + return HttpResponse.json(mockContractorContractDetailsSchema); + }, +); + const contractDocumentsHandler = http.get( '*/v1/employments/*/contract-documents', () => { @@ -333,6 +341,7 @@ export const defaultHandlers = [ contractorCurrenciesHandler, companyPricingPlansHandler, contractorBasicInformationHandler, + contractorContractDetailsSchemaHandler, contractDocumentsHandler, companyHandler, benefitOffersHandler, From 86b40d4f356dcc5201f1a8c69dd56e99323eca17 Mon Sep 17 00:00:00 2001 From: Jordi Vidaller Date: Thu, 17 Sep 2026 15:22:40 +0200 Subject: [PATCH 08/17] fix(contract-document): prefill the service start date with today The standalone flow copied onboarding and prefilled the start date with the employment's provisional start date. For an existing contractor that date is in the past, which on the Plus and Contractor of Record plans immediately raised the backdating warning. The Remote platform prefills today's date for a new contract document, so the flow does the same. Co-Authored-By: Claude Fable 5.1 --- src/flows/ContractDocument/README.md | 3 ++- src/flows/ContractDocument/hooks.tsx | 10 +++------- 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/src/flows/ContractDocument/README.md b/src/flows/ContractDocument/README.md index 47174473f..b0ea79f0e 100644 --- a/src/flows/ContractDocument/README.md +++ b/src/flows/ContractDocument/README.md @@ -133,7 +133,7 @@ prop, for fully custom UIs. Both surfaces expose the same bag: | `handleValidation` | Validation handler for the current step's form. | | `parseFormValues` | Turns the contract details values into the API payload without submitting. | | `onSubmit` | Creates the contract document. | -| `initialValues` | Initial form values per step, prefilled from the employment and the schema defaults. | +| `initialValues` | Initial form values per step: today as the start date, the current contract details, the schema defaults. | | `meta` | Field metadata for the current step: labels for error messages, and fieldsets. | | `employmentId` | The contractor the contract document will be created for. | | `employment` | The contractor's employment. | @@ -163,6 +163,7 @@ values sourced from their current contract — payment terms from the stored con absent those, the contractor's rate. Those defaults are compensation data and require one of `documents-management:create`, `people-contracts:create` or `hiring:update`; without them the schema comes back without `default` values and the form renders blank rather than failing. +The service start date is prefilled with today's date, as on the Remote platform. **Product.** The standalone flow has no pricing-plan step. The product is read off the employment's `contractor_type`: it decides whether the Contractor Services Agreement disclaimer diff --git a/src/flows/ContractDocument/hooks.tsx b/src/flows/ContractDocument/hooks.tsx index 37e7068e6..10a092911 100644 --- a/src/flows/ContractDocument/hooks.tsx +++ b/src/flows/ContractDocument/hooks.tsx @@ -1,4 +1,5 @@ import { useCallback, useMemo, useRef, useState } from 'react'; +import { format } from 'date-fns'; import { useQuery } from '@tanstack/react-query'; import { FieldValues } from 'react-hook-form'; import { @@ -117,16 +118,11 @@ export const useContractDocument = ({ () => getInitialValues(contractDetailsFields, { service_duration: { - provisional_start_date: - employment?.basic_information?.provisional_start_date, + provisional_start_date: format(new Date(), 'yyyy-MM-dd'), }, ...employment?.contract_details, }), - [ - contractDetailsFields, - employment?.basic_information?.provisional_start_date, - employment?.contract_details, - ], + [contractDetailsFields, employment?.contract_details], ); const createContractDocumentMutation = useCreateContractorContractDocument(); From 47b8245353b2dd8192bd6b78298e9886ebeec7f6 Mon Sep 17 00:00:00 2001 From: Jordi Vidaller Date: Thu, 17 Sep 2026 15:37:17 +0200 Subject: [PATCH 09/17] fix(contract-document): drop the start-date mismatch note from the standalone flow The note compares the start date with the one given at the Basic Information step and warns when they differ. In the standalone flow the contractor already exists and the field is prefilled with today, so the note would show on nearly every open. The shared helper is back to its onboarding-only signature. Co-Authored-By: Claude Fable 5.1 --- src/common/contract-documents/utils.ts | 3 +-- src/flows/ContractDocument/hooks.tsx | 12 +----------- 2 files changed, 2 insertions(+), 13 deletions(-) diff --git a/src/common/contract-documents/utils.ts b/src/common/contract-documents/utils.ts index 31975ef7f..0ffacf3f8 100644 --- a/src/common/contract-documents/utils.ts +++ b/src/common/contract-documents/utils.ts @@ -11,7 +11,6 @@ import { isMutationError } from '@/src/lib/mutations'; export const calculateProvisionalStartDateDescription = ( employmentProvisionalStartDate: string | undefined, fieldProvisionalStartDate: string | undefined, - currentDateLabel = 'the date you provided in the Basic Information step', ): string | undefined => { const datesNotMatching = employmentProvisionalStartDate && @@ -19,7 +18,7 @@ export const calculateProvisionalStartDateDescription = ( employmentProvisionalStartDate !== fieldProvisionalStartDate; if (datesNotMatching) { - const datesDontMatchWarning = `This date does not match ${currentDateLabel} - ${ + const datesDontMatchWarning = `This date does not match the date you provided in the Basic Information step - ${ employmentProvisionalStartDate } - and will override it only when both parties have signed the contract.`; return `When the contractor will start providing service to your company. ${datesDontMatchWarning}`; diff --git a/src/flows/ContractDocument/hooks.tsx b/src/flows/ContractDocument/hooks.tsx index 10a092911..13311691d 100644 --- a/src/flows/ContractDocument/hooks.tsx +++ b/src/flows/ContractDocument/hooks.tsx @@ -16,7 +16,6 @@ import { import { corProductIdentifier } from '@/src/common/contract-documents/constants'; import { buildContractDetailsJsfModify } from '@/src/common/contract-documents/jsfModify'; import { - calculateProvisionalStartDateDescription, extractAiValidationError, transformAiErrorResponse, } from '@/src/common/contract-documents/utils'; @@ -83,15 +82,6 @@ export const useContractDocument = ({ const productIdentifier = getProductIdentifier(employment?.contractor_type); const isContractorOfRecord = productIdentifier === corProductIdentifier; - const provisionalStartDateDescription = - calculateProvisionalStartDateDescription( - employment?.basic_information?.provisional_start_date as - | string - | undefined, - fieldValues?.service_duration?.provisional_start_date, - "the contractor's current start date", - ); - const { data: contractDetailsForm, isLoading: isLoadingContractDetailsForm } = useContractorContractDetailsSchema({ countryCode: countryCode as string, @@ -101,7 +91,7 @@ export const useContractDocument = ({ queryOptions: { enabled: Boolean(countryCode) }, jsfModify: buildContractDetailsJsfModify( options?.jsfModify?.contract_details, - provisionalStartDateDescription, + undefined, productIdentifier, fieldValues, isContractorOfRecord, From 3c28ec8fef07fa9e073952abf4207318d9b0f1fd Mon Sep 17 00:00:00 2001 From: Jordi Vidaller Date: Thu, 17 Sep 2026 16:13:37 +0200 Subject: [PATCH 10/17] fix(contract-document): expose query errors on the bag and block submission when the form cannot load Throwing from the schema query only left data undefined: the bag still reported isLoading false with no fields, so the submit button stayed enabled and a click sent an empty contract document. The bag now exposes the first error among the employment, contract-documents and schema queries, and the submit button is disabled while one is set. Co-Authored-By: Claude Fable 5.1 --- .../ContractDocumentSubmitButton.tsx | 3 +- src/flows/ContractDocument/hooks.tsx | 74 ++++++++++++------- 2 files changed, 48 insertions(+), 29 deletions(-) diff --git a/src/flows/ContractDocument/ContractDocumentSubmitButton.tsx b/src/flows/ContractDocument/ContractDocumentSubmitButton.tsx index 9c97cf5dc..b9a42d502 100644 --- a/src/flows/ContractDocument/ContractDocumentSubmitButton.tsx +++ b/src/flows/ContractDocument/ContractDocumentSubmitButton.tsx @@ -28,7 +28,8 @@ export function ContractDocumentSubmitButton({ disabled={ props.disabled || contractDocumentBag.isSubmitting || - !contractDocumentBag.employmentId + !contractDocumentBag.employmentId || + Boolean(contractDocumentBag.error) } > {children} diff --git a/src/flows/ContractDocument/hooks.tsx b/src/flows/ContractDocument/hooks.tsx index 13311691d..d3b4a2d7f 100644 --- a/src/flows/ContractDocument/hooks.tsx +++ b/src/flows/ContractDocument/hooks.tsx @@ -64,40 +64,49 @@ export const useContractDocument = ({ contract_details: {}, }); - const { data: employment, isLoading: isLoadingEmployment } = - useEmploymentQuery({ - employmentId, - queryParams: { exclude_files: true }, - enabled: Boolean(employmentId), - }); + const { + data: employment, + isLoading: isLoadingEmployment, + error: employmentError, + } = useEmploymentQuery({ + employmentId, + queryParams: { exclude_files: true }, + enabled: Boolean(employmentId), + }); - const { data: contractDocuments, isLoading: isLoadingContractDocuments } = - useQuery({ - ...contractDocumentsOptions(client as Client, employmentId), - enabled: Boolean(employmentId), - select: ({ data }) => data.data.contract_documents, - }); + const { + data: contractDocuments, + isLoading: isLoadingContractDocuments, + error: contractDocumentsError, + } = useQuery({ + ...contractDocumentsOptions(client as Client, employmentId), + enabled: Boolean(employmentId), + select: ({ data }) => data.data.contract_documents, + }); const countryCode = employment?.country?.code; const productIdentifier = getProductIdentifier(employment?.contractor_type); const isContractorOfRecord = productIdentifier === corProductIdentifier; - const { data: contractDetailsForm, isLoading: isLoadingContractDetailsForm } = - useContractorContractDetailsSchema({ - countryCode: countryCode as string, - employmentId, - fieldValues, - options: { - queryOptions: { enabled: Boolean(countryCode) }, - jsfModify: buildContractDetailsJsfModify( - options?.jsfModify?.contract_details, - undefined, - productIdentifier, - fieldValues, - isContractorOfRecord, - ), - }, - }); + const { + data: contractDetailsForm, + isLoading: isLoadingContractDetailsForm, + error: contractDetailsFormError, + } = useContractorContractDetailsSchema({ + countryCode: countryCode as string, + employmentId, + fieldValues, + options: { + queryOptions: { enabled: Boolean(countryCode) }, + jsfModify: buildContractDetailsJsfModify( + options?.jsfModify?.contract_details, + undefined, + productIdentifier, + fieldValues, + isContractorOfRecord, + ), + }, + }); const contractDetailsFields = useMemo( () => (contractDetailsForm?.fields ?? []) as Fields, @@ -332,5 +341,14 @@ export const useContractDocument = ({ * True while the contract document is being created. */ isSubmitting: createContractDocumentMutation.isPending, + /** + * The error that stopped the flow from loading, if any: the employment, its contract + * documents or the contract details schema could not be fetched. + */ + error: + employmentError ?? + contractDocumentsError ?? + contractDetailsFormError ?? + null, }; }; From c366b273f9d84365060e69106e20d59ecb6db7cd Mon Sep 17 00:00:00 2001 From: Jordi Vidaller Date: Thu, 17 Sep 2026 16:13:37 +0200 Subject: [PATCH 11/17] refactor(contract-document): drop the unused form values type Co-Authored-By: Claude Fable 5.1 --- src/flows/ContractDocument/index.ts | 1 - src/flows/ContractDocument/types.ts | 11 +---------- src/index.tsx | 1 - 3 files changed, 1 insertion(+), 12 deletions(-) diff --git a/src/flows/ContractDocument/index.ts b/src/flows/ContractDocument/index.ts index f8c30a1b7..6cc718631 100644 --- a/src/flows/ContractDocument/index.ts +++ b/src/flows/ContractDocument/index.ts @@ -3,7 +3,6 @@ export { ContractDocumentForm } from './ContractDocumentForm'; export { ContractDocumentSubmitButton } from './ContractDocumentSubmitButton'; export { useContractDocument } from './hooks'; export type { - ContractDocumentContractDetailsFormValues, ContractDocumentContractDetailsPayload, ContractDocumentContractDetailsResponse, ContractDocumentFlowProps, diff --git a/src/flows/ContractDocument/types.ts b/src/flows/ContractDocument/types.ts index d60002a1f..4a727a177 100644 --- a/src/flows/ContractDocument/types.ts +++ b/src/flows/ContractDocument/types.ts @@ -1,9 +1,6 @@ import React from 'react'; import { CreateContractDocument } from '@/src/client/types.gen'; -import { - ContractorContractDetailsFormPayload, - ContractorContractDetailsResponse, -} from '@/src/common/contract-documents/types'; +import { ContractorContractDetailsResponse } from '@/src/common/contract-documents/types'; import { useContractDocument } from '@/src/flows/ContractDocument/hooks'; import { JSFModify } from '@/src/flows/types'; @@ -38,12 +35,6 @@ export type ContractDocumentFlowProps = { ) => React.ReactNode; }; -/** - * Values the contract details form collects. - */ -export type ContractDocumentContractDetailsFormValues = - ContractorContractDetailsFormPayload; - /** * The payload sent to `POST /v1/contractors/employments/{employment_id}/contract-documents`. */ diff --git a/src/index.tsx b/src/index.tsx index d899dd660..ecbfc0196 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -39,7 +39,6 @@ export { } from '@/src/flows/ContractDocument'; export type { - ContractDocumentContractDetailsFormValues, ContractDocumentContractDetailsPayload, ContractDocumentContractDetailsResponse, ContractDocumentFlowProps, From 0be33c4b4627f386431639738a603710e816eb59 Mon Sep 17 00:00:00 2001 From: Jordi Vidaller Date: Thu, 17 Sep 2026 16:13:37 +0200 Subject: [PATCH 12/17] docs(contract-document): document the error key and the standard contractor fallback Co-Authored-By: Claude Fable 5.1 --- src/flows/ContractDocument/README.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/flows/ContractDocument/README.md b/src/flows/ContractDocument/README.md index b0ea79f0e..16376d737 100644 --- a/src/flows/ContractDocument/README.md +++ b/src/flows/ContractDocument/README.md @@ -113,7 +113,8 @@ contract document and moves the flow to `contract_preview`. ### ContractDocumentSubmitButton Must be rendered inside the flow's `render` prop. Accepts any button props; disables itself -while the contract document is being created, and while the flow has no contractor to act on. +while the contract document is being created, while the flow has no contractor to act on, and +when the flow failed to load (`error` is set on the bag). ## The bag @@ -144,6 +145,7 @@ prop, for fully custom UIs. Both surfaces expose the same bag: | `canSkipAiValidation` | True when the last submission was rejected by the AI check and submitting again continues at the user's risk. | | `isLoading` | True until the contractor and the current step's form are known. | | `isSubmitting` | True while the contract document is being created. | +| `error` | The error that stopped the flow from loading (employment, contract documents or schema), or `null`. | ## Requests @@ -168,6 +170,8 @@ The service start date is prefilled with today's date, as on the Remote platform **Product.** The standalone flow has no pricing-plan step. The product is read off the employment's `contractor_type`: it decides whether the Contractor Services Agreement disclaimer is shown, whether the start date can be backdated, and which misclassification wording is used. +An employment without a `contractor_type` is treated as a standard contractor, as in contractor +onboarding. ## AI misclassification check From c728705915582aa85c163a38ed4b907eb4f2d558 Mon Sep 17 00:00:00 2001 From: Jordi Vidaller Date: Thu, 17 Sep 2026 16:13:38 +0200 Subject: [PATCH 13/17] test(contract-document): assert the disclaimer shows for a standard contractor Co-Authored-By: Claude Fable 5.1 --- src/flows/ContractDocument/tests/ContractDocumentForm.test.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/flows/ContractDocument/tests/ContractDocumentForm.test.tsx b/src/flows/ContractDocument/tests/ContractDocumentForm.test.tsx index 929833276..33bbe48d6 100644 --- a/src/flows/ContractDocument/tests/ContractDocumentForm.test.tsx +++ b/src/flows/ContractDocument/tests/ContractDocumentForm.test.tsx @@ -98,6 +98,9 @@ describe('ContractDocumentForm', () => { renderFlow(); await fillContractDetails(); + expect( + screen.getByText('Contractor Services Agreement'), + ).toBeInTheDocument(); fireEvent.click(screen.getByRole('button', { name: 'Continue' })); From 920800d5678749c3c411cadfb6135bc0d6c56516 Mon Sep 17 00:00:00 2001 From: Jordi Vidaller Date: Tue, 22 Sep 2026 11:37:09 +0200 Subject: [PATCH 14/17] fix(contract-document): report a failed employment fetch and keep today as the start date The employment query resolves with no employment on an error response, so the bag reported no error and the submit button stayed enabled with an empty form. The bag now surfaces that as an error. The stored contract details used to override today's start date; today now wins, as the platform does and as the README states. Co-Authored-By: Claude Fable 5.1 --- src/flows/ContractDocument/hooks.tsx | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/flows/ContractDocument/hooks.tsx b/src/flows/ContractDocument/hooks.tsx index d3b4a2d7f..db78468cc 100644 --- a/src/flows/ContractDocument/hooks.tsx +++ b/src/flows/ContractDocument/hooks.tsx @@ -67,12 +67,18 @@ export const useContractDocument = ({ const { data: employment, isLoading: isLoadingEmployment, - error: employmentError, + isSuccess: hasEmploymentResponse, + error: employmentQueryError, } = useEmploymentQuery({ employmentId, queryParams: { exclude_files: true }, enabled: Boolean(employmentId), }); + const employmentError = + employmentQueryError ?? + (hasEmploymentResponse && !employment + ? new Error('Failed to fetch employment') + : null); const { data: contractDocuments, @@ -116,10 +122,11 @@ export const useContractDocument = ({ const contractDetailsInitialValues = useMemo( () => getInitialValues(contractDetailsFields, { + ...employment?.contract_details, service_duration: { + ...(employment?.contract_details?.service_duration as object), provisional_start_date: format(new Date(), 'yyyy-MM-dd'), }, - ...employment?.contract_details, }), [contractDetailsFields, employment?.contract_details], ); From f1bf33700b3a7c76e66ee0429f94b5f097e4cb18 Mon Sep 17 00:00:00 2001 From: Jordi Vidaller Date: Tue, 22 Sep 2026 11:37:09 +0200 Subject: [PATCH 15/17] docs(contract-document): any edit clears the AI misclassification warning Co-Authored-By: Claude Fable 5.1 --- src/flows/ContractDocument/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/flows/ContractDocument/README.md b/src/flows/ContractDocument/README.md index 16376d737..80103d113 100644 --- a/src/flows/ContractDocument/README.md +++ b/src/flows/ContractDocument/README.md @@ -178,5 +178,5 @@ onboarding. Remote checks the services and deliverables for misclassification risk when the contract document is created. When the check rejects the text with a skippable error, the form shows a warning under the field and `canSkipAiValidation` turns `true`; submitting again sends -`skip_ai_checks: true` and continues at the user's own risk. Editing the field clears the -warning and the next submission is checked again. +`skip_ai_checks: true` and continues at the user's own risk. Editing any field of the form +clears the warning and the next submission is checked again. From da2fcad419433dfc7d19fba8b84a5e1990da7298 Mon Sep 17 00:00:00 2001 From: Jordi Vidaller Date: Tue, 22 Sep 2026 16:37:47 +0200 Subject: [PATCH 16/17] refactor(contractor-onboarding): re-export the moved contract-details helpers from their old paths Co-Authored-By: Claude Fable 5.1 --- src/common/contract-documents/index.ts | 27 +------------------ src/flows/ContractorOnboarding/api.ts | 1 + .../components/StatementOfWorkDisclaimer.tsx | 1 + src/flows/ContractorOnboarding/constants.ts | 9 +++++++ src/flows/ContractorOnboarding/index.ts | 8 +++--- src/flows/ContractorOnboarding/jsfModify.tsx | 2 ++ .../ContractorOnboarding/tests/utils.test.ts | 3 +-- src/flows/ContractorOnboarding/types.ts | 1 + src/flows/ContractorOnboarding/utils.ts | 6 +++++ 9 files changed, 25 insertions(+), 33 deletions(-) create mode 100644 src/flows/ContractorOnboarding/components/StatementOfWorkDisclaimer.tsx diff --git a/src/common/contract-documents/index.ts b/src/common/contract-documents/index.ts index c28a9870f..071d0b72f 100644 --- a/src/common/contract-documents/index.ts +++ b/src/common/contract-documents/index.ts @@ -1,26 +1 @@ -export { - contractDocumentsOptions, - useCreateContractorContractDocument, - useGetContractDocuments, -} from './api'; -export { - contractorPlusProductIdentifier, - contractorStandardProductIdentifier, - corProductIdentifier, - REMOTE_AI_ERROR_SOURCE, - REMOTE_AI_SERVICES_AND_DELIVERABLES_COR_ERROR_MESSAGE, - REMOTE_AI_SERVICES_AND_DELIVERABLES_ERROR_MESSAGE, -} from './constants'; -export { buildContractDetailsJsfModify } from './jsfModify'; -export { - calculateProvisionalStartDateDescription, - extractAiValidationError, - isCMOrCMPlus, - transformAiErrorResponse, -} from './utils'; -export type { - AiValidationError, - ContractorContractDetailsFormPayload, - ContractorContractDetailsResponse, -} from './types'; -export { StatementOfWorkDisclaimer } from './components/StatementOfWorkDisclaimer'; +export { contractDocumentsOptions, useGetContractDocuments } from './api'; diff --git a/src/flows/ContractorOnboarding/api.ts b/src/flows/ContractorOnboarding/api.ts index 103298d62..280aca99a 100644 --- a/src/flows/ContractorOnboarding/api.ts +++ b/src/flows/ContractorOnboarding/api.ts @@ -20,6 +20,7 @@ import { } from '@/src/client'; import { useClient } from '@/src/context'; import { useCreateContractorContractDocument } from '@/src/common/contract-documents/api'; +export { useCreateContractorContractDocument } from '@/src/common/contract-documents/api'; import { signatureSchema } from '@/src/flows/ContractorOnboarding/json-schemas/signature'; import { contractOriginSchema } from '@/src/flows/ContractorOnboarding/json-schemas/contractOrigin'; import { invoiceScheduleSchema } from '@/src/flows/ContractorOnboarding/json-schemas/invoiceSchedule'; diff --git a/src/flows/ContractorOnboarding/components/StatementOfWorkDisclaimer.tsx b/src/flows/ContractorOnboarding/components/StatementOfWorkDisclaimer.tsx new file mode 100644 index 000000000..5c588207e --- /dev/null +++ b/src/flows/ContractorOnboarding/components/StatementOfWorkDisclaimer.tsx @@ -0,0 +1 @@ +export { StatementOfWorkDisclaimer } from '@/src/common/contract-documents/components/StatementOfWorkDisclaimer'; diff --git a/src/flows/ContractorOnboarding/constants.ts b/src/flows/ContractorOnboarding/constants.ts index 902f70f4f..6b76143fa 100644 --- a/src/flows/ContractorOnboarding/constants.ts +++ b/src/flows/ContractorOnboarding/constants.ts @@ -4,6 +4,15 @@ import { corProductIdentifier, } from '@/src/common/contract-documents/constants'; +export { + contractorPlusProductIdentifier, + contractorStandardProductIdentifier, + corProductIdentifier, + REMOTE_AI_ERROR_SOURCE, + REMOTE_AI_SERVICES_AND_DELIVERABLES_COR_ERROR_MESSAGE, + REMOTE_AI_SERVICES_AND_DELIVERABLES_ERROR_MESSAGE, +} from '@/src/common/contract-documents/constants'; + export const eorProductIdentifier = 'urn:remotecom:resource:product:eor:monthly'; diff --git a/src/flows/ContractorOnboarding/index.ts b/src/flows/ContractorOnboarding/index.ts index 334aa9ba3..72130a27b 100644 --- a/src/flows/ContractorOnboarding/index.ts +++ b/src/flows/ContractorOnboarding/index.ts @@ -17,12 +17,10 @@ export type { export type { ProductType } from './constants'; export { onboardingWorkflows, + contractorPlusProductIdentifier, + contractorStandardProductIdentifier, + corProductIdentifier, eorProductIdentifier, pricingPlanDetails, PRODUCT_IDENTIFIER_MAP, } from './constants'; -export { - contractorPlusProductIdentifier, - contractorStandardProductIdentifier, - corProductIdentifier, -} from '@/src/common/contract-documents/constants'; diff --git a/src/flows/ContractorOnboarding/jsfModify.tsx b/src/flows/ContractorOnboarding/jsfModify.tsx index 3d2aba53b..dd027fa92 100644 --- a/src/flows/ContractorOnboarding/jsfModify.tsx +++ b/src/flows/ContractorOnboarding/jsfModify.tsx @@ -9,6 +9,8 @@ import { isNationalityCountryCode } from '@/src/flows/ContractorOnboarding/utils import { FILE_TYPES, MAX_FILE_SIZE } from '@/src/lib/uploadConfig'; import { JSFCustomComponentProps } from '@/src/types/remoteFlows'; +export { buildContractDetailsJsfModify } from '@/src/common/contract-documents/jsfModify'; + /** * Builds the basic information jsf modify for the contractor onboarding flow * @param countryCode - The country code to use for the onboarding. diff --git a/src/flows/ContractorOnboarding/tests/utils.test.ts b/src/flows/ContractorOnboarding/tests/utils.test.ts index 865e28e02..18f67176f 100644 --- a/src/flows/ContractorOnboarding/tests/utils.test.ts +++ b/src/flows/ContractorOnboarding/tests/utils.test.ts @@ -3,8 +3,7 @@ import { getBasicInformationSchemaVersion, buildInvoicePreviewPayload, } from '../utils'; -import { eorProductIdentifier } from '../constants'; -import { corProductIdentifier } from '@/src/common/contract-documents/constants'; +import { corProductIdentifier, eorProductIdentifier } from '../constants'; describe('shouldIncludeProduct', () => { it('should return true when excludeProducts is empty', () => { diff --git a/src/flows/ContractorOnboarding/types.ts b/src/flows/ContractorOnboarding/types.ts index 6dec92103..e72fcbd8b 100644 --- a/src/flows/ContractorOnboarding/types.ts +++ b/src/flows/ContractorOnboarding/types.ts @@ -3,6 +3,7 @@ import { ContractorContractDetailsFormPayload, ContractorContractDetailsResponse, } from '@/src/common/contract-documents/types'; +export type { AiValidationError } from '@/src/common/contract-documents/types'; import { OnboardingBack } from '@/src/flows/ContractorOnboarding/components/OnboardingBack'; import { OnboardingSubmit } from '@/src/flows/ContractorOnboarding/components/OnboardingSubmit'; import { PricingPlanStep } from '@/src/flows/ContractorOnboarding/components/PricingPlan'; diff --git a/src/flows/ContractorOnboarding/utils.ts b/src/flows/ContractorOnboarding/utils.ts index 8511c247c..675d35da9 100644 --- a/src/flows/ContractorOnboarding/utils.ts +++ b/src/flows/ContractorOnboarding/utils.ts @@ -5,6 +5,12 @@ import { } from '@/src/flows/ContractorOnboarding/constants'; import { Employment } from '@/src/flows/Onboarding/types'; +export { + calculateProvisionalStartDateDescription, + isCMOrCMPlus, + transformAiErrorResponse, +} from '@/src/common/contract-documents/utils'; + export type StepKeys = | 'select_country' | 'basic_information' From 6135135a778af8fdabdf5e5feb48c8dd713eaad3 Mon Sep 17 00:00:00 2001 From: Jordi Vidaller Date: Tue, 22 Sep 2026 16:37:47 +0200 Subject: [PATCH 17/17] fix(contract-document): keep the flow usable when the contract documents list cannot load Co-Authored-By: Claude Fable 5.1 --- src/flows/ContractDocument/README.md | 2 +- src/flows/ContractDocument/hooks.tsx | 25 +++++++++---------------- 2 files changed, 10 insertions(+), 17 deletions(-) diff --git a/src/flows/ContractDocument/README.md b/src/flows/ContractDocument/README.md index 80103d113..49b716f66 100644 --- a/src/flows/ContractDocument/README.md +++ b/src/flows/ContractDocument/README.md @@ -145,7 +145,7 @@ prop, for fully custom UIs. Both surfaces expose the same bag: | `canSkipAiValidation` | True when the last submission was rejected by the AI check and submitting again continues at the user's risk. | | `isLoading` | True until the contractor and the current step's form are known. | | `isSubmitting` | True while the contract document is being created. | -| `error` | The error that stopped the flow from loading (employment, contract documents or schema), or `null`. | +| `error` | The error that stopped the flow from loading (employment or schema), or `null`. | ## Requests diff --git a/src/flows/ContractDocument/hooks.tsx b/src/flows/ContractDocument/hooks.tsx index db78468cc..1a3a36ce8 100644 --- a/src/flows/ContractDocument/hooks.tsx +++ b/src/flows/ContractDocument/hooks.tsx @@ -80,15 +80,12 @@ export const useContractDocument = ({ ? new Error('Failed to fetch employment') : null); - const { - data: contractDocuments, - isLoading: isLoadingContractDocuments, - error: contractDocumentsError, - } = useQuery({ - ...contractDocumentsOptions(client as Client, employmentId), - enabled: Boolean(employmentId), - select: ({ data }) => data.data.contract_documents, - }); + const { data: contractDocuments, isLoading: isLoadingContractDocuments } = + useQuery({ + ...contractDocumentsOptions(client as Client, employmentId), + enabled: Boolean(employmentId), + select: ({ data }) => data.data.contract_documents, + }); const countryCode = employment?.country?.code; const productIdentifier = getProductIdentifier(employment?.contractor_type); @@ -349,13 +346,9 @@ export const useContractDocument = ({ */ isSubmitting: createContractDocumentMutation.isPending, /** - * The error that stopped the flow from loading, if any: the employment, its contract - * documents or the contract details schema could not be fetched. + * The error that stopped the flow from loading, if any: the employment or the contract + * details schema could not be fetched. */ - error: - employmentError ?? - contractDocumentsError ?? - contractDetailsFormError ?? - null, + error: employmentError ?? contractDetailsFormError ?? null, }; };