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…

-
+ <> +
+ +
+
+

+ {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/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/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/common/contract-documents/components/StatementOfWorkDisclaimer.tsx b/src/common/contract-documents/components/StatementOfWorkDisclaimer.tsx new file mode 100644 index 000000000..67cfb3a82 --- /dev/null +++ b/src/common/contract-documents/components/StatementOfWorkDisclaimer.tsx @@ -0,0 +1,26 @@ +import { Statement } from '@/src/components/form/Statement'; +import { + contractorPlusProductIdentifier, + contractorStandardProductIdentifier, +} from '@/src/common/contract-documents/constants'; + +const CSA_DISCLAIMER = { + [contractorStandardProductIdentifier]: + ' Remote provides its Contractor Services Agreement (CSA) “as is”; it does not constitute legal advice or create an attorney-client relationship with Remote. Remote is not liable for the content You enter herein and You are encouraged to seek independent legal advice tailored to the specific relationship between You and Your Contractor. By use of the CSA, You release Remote from liability for any claim for damages or losses which may arise related to Your use thereof.', + [contractorPlusProductIdentifier]: + 'Remote provides its Contractor Services Agreement (CSA) “as is”; it does not constitute legal advice or create an attorney-client relationship with Remote. Remote is not liable for the content You enter herein and You are encouraged to seek independent legal advice tailored to the specific relationship between You and Your Contractor.', +}; + +export const StatementOfWorkDisclaimer = ({ + subscription, +}: { + subscription: string; +}) => { + return ( + + ); +}; 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/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/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..3bb398526 --- /dev/null +++ b/src/flows/ContractDocument/ContractDocumentForm.tsx @@ -0,0 +1,109 @@ +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.stepState.values?.contract_details ?? + 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, + contractDocumentBag.meta.fields.contract_details, + 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..b9a42d502 --- /dev/null +++ b/src/flows/ContractDocument/ContractDocumentSubmitButton.tsx @@ -0,0 +1,38 @@ +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..49b716f66 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,91 @@ 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, while the flow has no contractor to act on, and +when the flow failed to load (`error` is set on the bag). ## 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: 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. | +| `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. | +| `error` | The error that stopped the flow from loading (employment or schema), or `null`. | ## 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. +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 + +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 any field of the form +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..1a3a36ce8 100644 --- a/src/flows/ContractDocument/hooks.tsx +++ b/src/flows/ContractDocument/hooks.tsx @@ -1,15 +1,43 @@ +import { useCallback, useMemo, useRef, useState } from 'react'; +import { format } from 'date-fns'; 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 { + 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, prettifyFormValues } from '@/src/lib/utils'; +import { NestedMeta } from '@/src/types/remoteFlows'; /** * Headless hook powering the standalone contract-document flow: the contract details and @@ -18,17 +46,39 @@ 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 fieldsMetaRef = useRef<{ contract_details: NestedMeta }>({ + contract_details: {}, + }); - const { data: employment, isLoading: isLoadingEmployment } = - useEmploymentQuery({ - employmentId, - queryParams: { exclude_files: true }, - enabled: Boolean(employmentId), - }); + const { + data: employment, + isLoading: isLoadingEmployment, + 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, isLoading: isLoadingContractDocuments } = useQuery({ @@ -37,6 +87,160 @@ 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 { + 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, + [contractDetailsForm?.fields], + ); + + 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'), + }, + }), + [contractDetailsFields, 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 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 + } = parsedValues; + + return { + contract_document: contractDetails, + skip_ai_checks: + fieldValues.services_and_deliverables_error_skippable === true, + }; + }, + [fieldValues.services_and_deliverables_error_skippable], + ); + + const parseFormValues = useCallback( + async (values: FieldValues) => + buildPayload(await parseContractDetails(values)), + [buildPayload, parseContractDetails], + ); + + const onSubmit = useCallback( + async (values: FieldValues) => { + if (!employmentId) { + throw createStructuredError( + 'No contractor to act on. Pass a non-empty `employmentId` to the flow.', + ); + } + + const parsedValues = await parseContractDetails(values); + fieldsMetaRef.current.contract_details = prettifyFormValues( + parsedValues, + contractDetailsFields, + ); + const payload = buildPayload(parsedValues); + + 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, + parseContractDetails, + contractDetailsFields, + buildPayload, + 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 +265,42 @@ 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: { + fields: fieldsMetaRef.current, + fieldsets: isContractDetailsStep + ? contractDetailsForm?.meta?.['x-jsf-fieldsets'] + : undefined, + }, /** * The contractor the contract document will be created for. */ @@ -73,21 +312,43 @@ 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 the contract document is being created. + */ + isSubmitting: createContractDocumentMutation.isPending, /** - * True while a submission is in flight. + * The error that stopped the flow from loading, if any: the employment or the contract + * details schema could not be fetched. */ - isSubmitting: false, + error: employmentError ?? contractDetailsFormError ?? null, }; }; diff --git a/src/flows/ContractDocument/index.ts b/src/flows/ContractDocument/index.ts index 4008b30c7..6cc718631 100644 --- a/src/flows/ContractDocument/index.ts +++ b/src/flows/ContractDocument/index.ts @@ -1,7 +1,12 @@ export { ContractDocumentFlow } from './ContractDocumentFlow'; +export { ContractDocumentForm } from './ContractDocumentForm'; +export { ContractDocumentSubmitButton } from './ContractDocumentSubmitButton'; export { useContractDocument } from './hooks'; export type { + 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..33bbe48d6 --- /dev/null +++ b/src/flows/ContractDocument/tests/ContractDocumentForm.test.tsx @@ -0,0 +1,191 @@ +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, + 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 }, + ); +} + +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[] = []; + + beforeEach(() => { + queryClient.clear(); + createdDocuments.length = 0; + + mockEmployment(); + }); + + 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(); + expect( + screen.getByText('Contractor Services Agreement'), + ).toBeInTheDocument(); + + 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('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( + '*/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..4a727a177 100644 --- a/src/flows/ContractDocument/types.ts +++ b/src/flows/ContractDocument/types.ts @@ -1,13 +1,23 @@ import React from 'react'; +import { CreateContractDocument } from '@/src/client/types.gen'; +import { 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 +26,19 @@ 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; }; + +/** + * 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/flows/ContractorOnboarding/api.ts b/src/flows/ContractorOnboarding/api.ts index a8b14eafe..280aca99a 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,8 @@ import { PostV1EmploymentsEmploymentIdContractOriginData, } 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'; @@ -39,6 +39,8 @@ import { contractorPlusProductIdentifier, contractorStandardProductIdentifier, corProductIdentifier, +} from '@/src/common/contract-documents/constants'; +import { eorProductIdentifier, FEATURES_BY_IDENTIFIER, IR35_FILE_SUBTYPE, @@ -244,33 +246,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/components/StatementOfWorkDisclaimer.tsx b/src/flows/ContractorOnboarding/components/StatementOfWorkDisclaimer.tsx index 596b47c79..5c588207e 100644 --- a/src/flows/ContractorOnboarding/components/StatementOfWorkDisclaimer.tsx +++ b/src/flows/ContractorOnboarding/components/StatementOfWorkDisclaimer.tsx @@ -1,26 +1 @@ -import { Statement } from '@/src/components/form/Statement'; -import { - contractorPlusProductIdentifier, - contractorStandardProductIdentifier, -} from '@/src/flows/ContractorOnboarding/constants'; - -const CSA_DISCLAIMER = { - [contractorStandardProductIdentifier]: - ' Remote provides its Contractor Services Agreement (CSA) “as is”; it does not constitute legal advice or create an attorney-client relationship with Remote. Remote is not liable for the content You enter herein and You are encouraged to seek independent legal advice tailored to the specific relationship between You and Your Contractor. By use of the CSA, You release Remote from liability for any claim for damages or losses which may arise related to Your use thereof.', - [contractorPlusProductIdentifier]: - 'Remote provides its Contractor Services Agreement (CSA) “as is”; it does not constitute legal advice or create an attorney-client relationship with Remote. Remote is not liable for the content You enter herein and You are encouraged to seek independent legal advice tailored to the specific relationship between You and Your Contractor.', -}; - -export const StatementOfWorkDisclaimer = ({ - subscription, -}: { - subscription: string; -}) => { - return ( - - ); -}; +export { StatementOfWorkDisclaimer } from '@/src/common/contract-documents/components/StatementOfWorkDisclaimer'; diff --git a/src/flows/ContractorOnboarding/constants.ts b/src/flows/ContractorOnboarding/constants.ts index 4be129799..6b76143fa 100644 --- a/src/flows/ContractorOnboarding/constants.ts +++ b/src/flows/ContractorOnboarding/constants.ts @@ -1,9 +1,18 @@ -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 { + 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'; @@ -18,14 +27,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/jsfModify.tsx b/src/flows/ContractorOnboarding/jsfModify.tsx index f5ee8e3ce..dd027fa92 100644 --- a/src/flows/ContractorOnboarding/jsfModify.tsx +++ b/src/flows/ContractorOnboarding/jsfModify.tsx @@ -1,165 +1,15 @@ -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, - }), - }, - }, - }, - }, - }; -}; +export { buildContractDetailsJsfModify } from '@/src/common/contract-documents/jsfModify'; /** * Builds the basic information jsf modify for the contractor onboarding flow diff --git a/src/flows/ContractorOnboarding/types.ts b/src/flows/ContractorOnboarding/types.ts index 9a3e591a9..e72fcbd8b 100644 --- a/src/flows/ContractorOnboarding/types.ts +++ b/src/flows/ContractorOnboarding/types.ts @@ -1,8 +1,9 @@ +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'; +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'; @@ -161,33 +162,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..675d35da9 100644 --- a/src/flows/ContractorOnboarding/utils.ts +++ b/src/flows/ContractorOnboarding/utils.ts @@ -1,14 +1,16 @@ 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'; +export { + calculateProvisionalStartDateDescription, + isCMOrCMPlus, + transformAiErrorResponse, +} from '@/src/common/contract-documents/utils'; + export type StepKeys = | 'select_country' | 'basic_information' @@ -111,38 +113,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 - */ -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']; /** @@ -192,21 +162,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 - */ -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; /** diff --git a/src/index.tsx b/src/index.tsx index b12ea2919..ecbfc0196 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -33,11 +33,16 @@ export type { export { ContractDocumentFlow, + ContractDocumentForm, + ContractDocumentSubmitButton, useContractDocument, } from '@/src/flows/ContractDocument'; export type { + ContractDocumentContractDetailsPayload, + ContractDocumentContractDetailsResponse, ContractDocumentFlowProps, + ContractDocumentOptions, ContractDocumentStepKeys, UseContractDocumentOptions, } from '@/src/flows/ContractDocument'; 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,