diff --git a/.sizelimit.json b/.sizelimit.json index e80a99a33..07eb863e5 100644 --- a/.sizelimit.json +++ b/.sizelimit.json @@ -1,6 +1,6 @@ { "limits": { - "total": 600000, + "total": 650000, "totalGzip": 250000, "css": 150000, "cssGzip": 25000, diff --git a/example/api/jwt_auth.js b/example/api/jwt_auth.js index a72d6c199..aeff91859 100644 --- a/example/api/jwt_auth.js +++ b/example/api/jwt_auth.js @@ -86,6 +86,11 @@ async function fetchCompanyManagerToken() { // Express route handler async function getCompanyManagerToken(req, res) { + if (process.env.NODE_ENV === 'production') { + return res + .status(403) + .json({ error: 'This endpoint is not available in production mode' }); + } try { const { accessToken, expiresIn } = await fetchCompanyManagerToken(); @@ -101,8 +106,88 @@ async function getCompanyManagerToken(req, res) { } } +const EMPLOYEE_SCOPES = 'all:write'; + +async function fetchEmployeeToken(employmentId) { + const { VITE_CLIENT_ID, VITE_CLIENT_SECRET, VITE_REMOTE_GATEWAY } = + process.env; + + if ( + !VITE_CLIENT_ID || + (!VITE_CLIENT_SECRET && VITE_REMOTE_GATEWAY !== 'local') || + !VITE_REMOTE_GATEWAY || + !employmentId + ) { + throw new Error( + 'Missing VITE_CLIENT_ID, VITE_CLIENT_SECRET, or employmentId', + ); + } + + const gatewayUrl = buildGatewayURL(); + const now = Math.floor(Date.now() / 1000); + const exp = now + 5 * 60; + + const payload = { + iss: VITE_CLIENT_ID, + sub: `urn:remote-api:employee:employment:${employmentId}`, + aud: `${gatewayUrl}/auth`, + exp, + scope: EMPLOYEE_SCOPES, + iat: now, + }; + + const jwtToken = jwt.sign(payload, VITE_CLIENT_SECRET, { + algorithm: 'HS256', + }); + + const encodedCredentials = Buffer.from( + `${VITE_CLIENT_ID}:${VITE_CLIENT_SECRET}`, + ).toString('base64'); + + const response = await fetch(`${gatewayUrl}/auth/oauth2/token`, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + Authorization: `Basic ${encodedCredentials}`, + }, + body: new URLSearchParams({ + grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer', + assertion: jwtToken, + scope: EMPLOYEE_SCOPES, + }), + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`HTTP ${response.status}: ${errorText}`); + } + + const data = await response.json(); + return { accessToken: data.access_token, expiresIn: data.expires_in }; +} + +async function getEmployeeToken(req, res) { + if (process.env.NODE_ENV === 'production') { + return res + .status(403) + .json({ error: 'This endpoint is not available in production mode' }); + } + const { employmentId } = req.params; + try { + const { accessToken, expiresIn } = await fetchEmployeeToken(employmentId); + return res + .status(200) + .json({ access_token: accessToken, expires_in: expiresIn }); + } catch (error) { + console.error('Error fetching employee token:', error); + return res.status(500).json({ error: error.message }); + } +} + module.exports = { getCompanyManagerToken, generateJWTToken, fetchCompanyManagerToken, + fetchEmployeeToken, + getEmployeeToken, }; diff --git a/example/api/proxy.js b/example/api/proxy.js index 7e481fe12..7a283802e 100644 --- a/example/api/proxy.js +++ b/example/api/proxy.js @@ -3,25 +3,30 @@ const { fetchClientCredentialsAccessToken, fetchAccessToken, } = require('./get_token.js'); +const { fetchEmployeeToken } = require('./jwt_auth.js'); const { buildGatewayURL } = require('./utils.js'); /** * Determines which token type to use based on HTTP method and path * @param {string} method - HTTP method (GET, POST, PUT, PATCH, etc.) * @param {string} path - The API path (e.g., '/v1/countries' or '/v2/countries?foo=bar') - * @returns {'client-credentials' | 'user-token'} The token type to use + * @returns {'client-credentials' | 'user-token' | 'employee-assertion'} The token type to use */ function getTokenType(method, path) { const normalizedMethod = method.toUpperCase(); // Extract pathname without query parameters const pathname = path.split('?')[0].toLowerCase(); - // GET /v1/countries or /v2/countries + // GET /v1/countries or /v2/countries — these don't require a user identity; + // use client_credentials so the call works in CI where no user token is + // available. Local dev without a client secret can opt into a user token + // by setting VITE_REMOTE_GATEWAY=... and ensuring VITE_CLIENT_TOKEN works. if (normalizedMethod === 'GET' && /^\/v[12]\/countries$/.test(pathname)) { return 'client-credentials'; } - // GET /v1/countries/{country_code}/address_details or /v2/countries/{country_code}/address_details + // GET /v[12]/countries/{country_code}/address_details — public reference + // data; also use client credentials. if ( normalizedMethod === 'GET' && /^\/v[12]\/countries\/[^/]+\/address_details$/.test(pathname) @@ -50,6 +55,13 @@ function getTokenType(method, path) { return 'client-credentials'; } + // /v1/employee/* endpoints need an employment-scoped assertion. The FE + // identifies which employment via the x-rf-employment-id header; the proxy + // mints the JWT-bearer token server-side so the FE never sees it. + if (/^\/v1\/employee\//.test(pathname)) { + return 'employee-assertion'; + } + // All other requests use user token return 'user-token'; } @@ -94,11 +106,25 @@ async function createProxyRequest(path, method = 'GET', options = {}) { // Add authentication if required if (requiresAuth) { const tokenType = getTokenType(method, path); - const { accessToken } = - tokenType === 'client-credentials' - ? await fetchClientCredentialsAccessToken() - : await fetchAccessToken(); + let accessToken; + if (tokenType === 'client-credentials') { + ({ accessToken } = await fetchClientCredentialsAccessToken()); + } else if (tokenType === 'employee-assertion') { + const employmentId = headers['x-rf-employment-id']; + if (!employmentId) { + throw Object.assign( + new Error('Missing x-rf-employment-id header for employee request'), + { + response: { status: 400, data: { error: 'employmentId required' } }, + }, + ); + } + ({ accessToken } = await fetchEmployeeToken(employmentId)); + } else { + ({ accessToken } = await fetchAccessToken()); + } requestConfig.headers.Authorization = `Bearer ${accessToken}`; + delete requestConfig.headers['x-rf-employment-id']; } return axios(requestConfig); diff --git a/example/api/routes.js b/example/api/routes.js index 2a51b3817..302746bda 100644 --- a/example/api/routes.js +++ b/example/api/routes.js @@ -1,11 +1,12 @@ const { getToken } = require('./get_token.js'); -const { getCompanyManagerToken } = require('./jwt_auth.js'); +const { getCompanyManagerToken, getEmployeeToken } = require('./jwt_auth.js'); const { createProxyMiddleware } = require('./proxy.js'); function setupRoutes(app) { // API routes app.get('/api/fetch-refresh-token', getToken); app.get('/api/fetch-company-manager', getCompanyManagerToken); + app.get('/api/fetch-employee-token/:employmentId', getEmployeeToken); // Proxy all versioned API routes (v1, v2, etc.) app.use(/^\/v\d+/, createProxyMiddleware()); diff --git a/example/src/App.tsx b/example/src/App.tsx index da4c367ae..c875f12b9 100644 --- a/example/src/App.tsx +++ b/example/src/App.tsx @@ -47,6 +47,8 @@ import { ContractorOnboardingForm } from './ContractorOnboarding'; import { CreateCompanyForm } from './CreateCompany'; import { MagicLinkTest } from './MagicLinkTest'; import { JsonSchemaComparisonDemo } from './JsonSchemaComparisonDemo'; +import { PayrollAdminOnboardingForm } from './PayrollAdminOnboarding'; +import { PayrollEmployeeOnboardingForm } from './PayrollEmployeeOnboarding'; import ContractorOnboardingCode from './ContractorOnboarding?raw'; import CreateCompanyCode from './CreateCompany?raw'; import MagicLinkTestCode from './MagicLinkTest?raw'; @@ -175,6 +177,20 @@ const additionalDemos = [ component: JsonSchemaComparisonDemo, sourceCode: JsonSchemaComparisonCode, }, + { + id: 'payroll-admin-onboarding', + title: 'GP Admin Onboarding', + description: 'Global Payroll admin onboarding flow', + component: PayrollAdminOnboardingForm, + sourceCode: '', + }, + { + id: 'payroll-employee-onboarding', + title: 'GP Employee Onboarding', + description: 'Global Payroll employee self-onboarding flow', + component: PayrollEmployeeOnboardingForm, + sourceCode: '', + }, ]; const demoStructure = [ diff --git a/example/src/PayrollAdminOnboarding.tsx b/example/src/PayrollAdminOnboarding.tsx new file mode 100644 index 000000000..41a757ce0 --- /dev/null +++ b/example/src/PayrollAdminOnboarding.tsx @@ -0,0 +1,257 @@ +import { useState } from 'react'; +import { + PayrollAdminOnboardingFlow, + PayrollAdminOnboardingRenderProps, + useGPLegalEntities, +} from '@remoteoss/remote-flows'; +import { RemoteFlows } from './RemoteFlows'; +import { AlertError } from './AlertError'; +import './css/main.css'; + +const COMPANY_ID = import.meta.env.VITE_COMPANY_ID as string; + +const STEP_LABELS: Record = { + select_country: 'Country & Basic Info', + contract_details: 'Contract Details', + administrative_details: 'Administrative Details', + invite: 'Send Invitation', +}; + +const STEP_DESCRIPTIONS: Record = { + select_country: + "Select the employee's country and fill in their basic information.", + contract_details: + 'Define the employment contract terms for this Global Payroll employee.', + administrative_details: + 'Provide administrative details required for payroll processing.', + invite: + 'Send the invitation email so the employee can complete their self-onboarding.', +}; + +type Errors = { + apiError: string; + fieldErrors: { + field: string; + messages: string[]; + userFriendlyLabel: string; + }[]; +}; + +const emptyErrors: Errors = { apiError: '', fieldErrors: [] }; + +function AdminFlowForm({ legalEntityId }: { legalEntityId: string }) { + const [errors, setErrors] = useState(emptyErrors); + const [done, setDone] = useState(false); + + const clearErrors = () => setErrors(emptyErrors); + + const handleError = (error: Error, fieldErrors: Errors['fieldErrors']) => { + setErrors({ apiError: error.message, fieldErrors }); + }; + + if (done) { + return ( +
+

Invitation Sent

+

+ The employee will receive an email to complete their self-onboarding. +

+
+ +
+
+ ); + } + + return ( + { + const { + SelectCountryStep, + ContractDetailsStep, + AdministrativeDetailsStep, + InvitationStep, + SubmitButton, + BackButton, + } = components; + + const currentStep = adminBag.stepState.currentStep.name; + const allSteps = Object.entries(STEP_LABELS); + + if (adminBag.isLoading && !adminBag.countryCode) { + return

Loading...

; + } + + const onStepError = (e: { + error: Error; + fieldErrors: { field: string; messages: string[] }[]; + }) => + handleError( + e.error, + e.fieldErrors.map((fe) => ({ + ...fe, + userFriendlyLabel: fe.field, + })), + ); + + const renderStep = () => { + switch (currentStep) { + case 'select_country': { + const isFormReady = + !!adminBag.countryCode && adminBag.fields.length > 0; + return ( + <> + + + {isFormReady && ( +
+ + Create Employment & Continue + +
+ )} + + ); + } + case 'contract_details': + return ( + <> + + +
+ + Previous Step + + + Save & Continue + +
+ + ); + case 'administrative_details': + return ( + <> + + +
+ + Previous Step + + + Save & Continue + +
+ + ); + case 'invite': + return ( + <> + +
+ + Previous Step + + { + clearErrors(); + setDone(true); + }} + onError={onStepError} + > + Send Invitation + +
+ + ); + default: + return null; + } + }; + + return ( + <> +
+
    + {allSteps.map(([key, label], index) => ( +
  • + {index + 1} + {label} +
  • + ))} +
+
+ +
+

{STEP_LABELS[currentStep]}

+

+ {STEP_DESCRIPTIONS[currentStep]} +

+ {renderStep()} +
+ + ); + }} + /> + ); +} + +function GPAdminOnboardingInner() { + const { data: legalEntities, isLoading } = useGPLegalEntities(COMPANY_ID); + + if (isLoading) { + return

Loading…

; + } + + if (!legalEntities || legalEntities.length === 0) { + return ( +
+

+ No GP-enabled legal entity found. The company{' '} + {COMPANY_ID} has no legal entity with Global Payroll + enabled. +

+
+ ); + } + + return ; +} + +export function PayrollAdminOnboardingForm() { + return ( + + + + ); +} diff --git a/example/src/PayrollEmployeeOnboarding.tsx b/example/src/PayrollEmployeeOnboarding.tsx new file mode 100644 index 000000000..02d9becfe --- /dev/null +++ b/example/src/PayrollEmployeeOnboarding.tsx @@ -0,0 +1,543 @@ +import { useState } from 'react'; +import { + PayrollEmployeeOnboardingFlow, + PayrollEmployeeOnboardingRenderProps, + TaxStepUnavailableReason, + useEmploymentQuery, + useGPOnboardingSteps, +} from '@remoteoss/remote-flows'; +import { RemoteFlows } from './RemoteFlows'; +import { AlertError } from './AlertError'; +import './css/main.css'; + +const EMPLOYMENT_ID = (import.meta.env.VITE_EMPLOYMENT_ID as string) || ''; + +const STEP_LABELS: Record = { + personal_details: 'Personal Details', + home_address: 'Home Address', + bank_account: 'Bank Account', + federal_taxes: 'Federal Taxes (W-4)', + state_taxes: 'State Taxes', +}; + +const STEP_DESCRIPTIONS: Record = { + personal_details: + 'Provide your personal information for the employment record.', + home_address: 'Enter your home address for payroll and compliance purposes.', + bank_account: 'Add your bank account details to receive payroll payments.', + federal_taxes: + 'Set your federal income tax withholding preferences (USA only). Becomes available after your employment is activated.', + state_taxes: + 'Set your state tax withholding preferences for the selected jurisdiction (USA only). Becomes available after your employment is activated.', +}; + +type Errors = { + apiError: string; + fieldErrors: { + field: string; + messages: string[]; + userFriendlyLabel: string; + }[]; +}; + +const emptyErrors: Errors = { apiError: '', fieldErrors: [] }; + +// ── Outer-context loader (company manager token) ──────────────────────────── +// +// The employee assertion token can't fetch /v1/employments/:id (returns +// {message} only), so we read country + work jurisdiction from the employment +// here, in the outer RemoteFlows context, and hand them down to the inner +// (employee-token) context as props. This keeps consumers from having to +// hardcode VITE_GP_COUNTRY_CODE / VITE_GP_STATE_JURISDICTION. + +function useEmployeeFlowContext(employmentId: string) { + const { data: apiSteps, isLoading: isLoadingSteps } = + useGPOnboardingSteps(employmentId); + const { data: employment, isLoading: isLoadingEmployment } = + useEmploymentQuery({ employmentId }); + + const selfOnboarding = apiSteps?.find( + (s: { type: string }) => s.type === 'self_onboarding', + ); + const substeps = (selfOnboarding?.sub_steps ?? []) as { type: string }[]; + const hasBankAccount = substeps.some( + (s: { type: string }) => s.type === 'employee_provides_bank_details', + ); + + const countryCode = employment?.country?.code; + // Prefer the work address state when present; fall back to the home address + // state. State code is only meaningful for USA — the SDK ignores + // `jurisdiction` for non-USA employments. + const workState = ( + employment?.work_address_details as { state?: string } | undefined + )?.state; + const homeState = ( + employment?.address_details as { state?: string } | undefined + )?.state; + const jurisdiction = workState || homeState; + + return { + substeps, + hasBankAccount, + countryCode, + jurisdiction, + isLoading: isLoadingSteps || isLoadingEmployment, + }; +} + +function TaxStepNotAvailable({ + reason, + jurisdiction, +}: { + reason: TaxStepUnavailableReason; + jurisdiction?: string; +}) { + let message: string; + if (reason === 'unsupported_country') { + message = 'Tax steps are only available for USA employments.'; + } else if (reason === 'no_jurisdiction') { + message = + 'A US state code is required to submit state taxes — pass a `jurisdiction` prop on the flow.'; + } else if (reason === 'schema_unavailable') { + message = jurisdiction + ? `The backend didn't return a form schema for state_taxes (jurisdiction "${jurisdiction}"). Check that the gateway has the schema configured for this country/jurisdiction.` + : `The backend didn't return a form schema for federal_taxes. Check that the gateway has the schema configured for USA.`; + } else { + message = jurisdiction + ? `Your employment isn't active yet, so the tax_task for jurisdiction "${jurisdiction}" hasn't been created. Come back after activation.` + : `Your employment isn't active yet, so the federal_taxes tax_task hasn't been created. Come back after activation.`; + } + return ( +
+

+ Step unavailable. {message} +

+
+ ); +} + +// ── Employee form (employee-scoped token, inner context) ──────────────────── + +function EmployeeFlowInner({ + employmentId, + hasBankAccount, + countryCode, + jurisdiction, +}: { + employmentId: string; + hasBankAccount: boolean; + countryCode: string; + jurisdiction: string | undefined; +}) { + const [errors, setErrors] = useState(emptyErrors); + const [done, setDone] = useState(false); + + const clearErrors = () => setErrors(emptyErrors); + const handleError = (error: Error, fieldErrors: Errors['fieldErrors']) => + setErrors({ apiError: error.message, fieldErrors }); + + const isUSA = countryCode === 'USA'; + + // Visible steps depend on country + bank substep + jurisdiction availability. + // Tax steps are surfaced for USA even if not yet active — the bag exposes a + // not-available reason so we can render a friendly state in-place. + const allSteps = Object.entries(STEP_LABELS); + const visibleSteps = allSteps.filter(([key]) => { + if (key === 'bank_account') return hasBankAccount; + if (key === 'federal_taxes') return isUSA; + if (key === 'state_taxes') return isUSA && !!jurisdiction; + return true; + }); + const lastStepKey = visibleSteps[visibleSteps.length - 1][0]; + + if (done) { + return ( +
+

Self-onboarding Complete

+

+ All your information has been submitted. Your employer will review and + activate your employment. +

+
+ +
+
+ ); + } + + return ( + { + const { + PersonalDetailsStep, + HomeAddressStep, + BankAccountStep, + FederalTaxesStep, + StateTaxesStep, + SubmitButton, + BackButton, + } = components; + + const currentStep = employeeBag.stepState.currentStep.name; + + if (employeeBag.isLoading && !employeeBag.fields.length) { + return

Loading...

; + } + + const isLastStep = currentStep === lastStepKey; + + const federalAvail = employeeBag.taxStepsAvailability.federal_taxes; + const stateAvail = employeeBag.taxStepsAvailability.state_taxes; + + return ( + <> +
+
    + {visibleSteps.map(([key, label], index) => ( +
  • + {index + 1} + {label} +
  • + ))} +
+
+ +
+

{STEP_LABELS[currentStep]}

+

+ {STEP_DESCRIPTIONS[currentStep]} +

+ + {currentStep === 'personal_details' && ( + <> + + handleError( + e.error, + e.fieldErrors.map((fe) => ({ + ...fe, + userFriendlyLabel: fe.field, + })), + ) + } + onSuccess={clearErrors} + /> + + {employeeBag.fields.length > 0 && ( +
+ + Save & Continue + +
+ )} + + )} + + {currentStep === 'home_address' && ( + <> + + handleError( + e.error, + e.fieldErrors.map((fe) => ({ + ...fe, + userFriendlyLabel: fe.field, + })), + ) + } + onSuccess={() => { + clearErrors(); + if (isLastStep) setDone(true); + }} + /> + +
+ + Previous Step + + + {isLastStep ? 'Submit' : 'Save & Continue'} + +
+ + )} + + {currentStep === 'bank_account' && hasBankAccount && ( + <> + + handleError( + e.error, + e.fieldErrors.map((fe) => ({ + ...fe, + userFriendlyLabel: fe.field, + })), + ) + } + onSuccess={() => { + clearErrors(); + if (isLastStep) setDone(true); + }} + /> + +
+ + Previous Step + + + {isLastStep ? 'Submit' : 'Save & Continue'} + +
+ + )} + + {currentStep === 'federal_taxes' && ( + <> + {federalAvail.isAvailable ? ( + + handleError( + e.error, + e.fieldErrors.map((fe) => ({ + ...fe, + userFriendlyLabel: fe.field, + })), + ) + } + onSuccess={() => { + clearErrors(); + if (isLastStep) setDone(true); + }} + /> + ) : ( + + )} + +
+ + Previous Step + + {federalAvail.isAvailable ? ( + + {isLastStep ? 'Submit' : 'Save & Continue'} + + ) : ( + + )} +
+ + )} + + {currentStep === 'state_taxes' && ( + <> + {stateAvail.isAvailable ? ( + + handleError( + e.error, + e.fieldErrors.map((fe) => ({ + ...fe, + userFriendlyLabel: fe.field, + })), + ) + } + onSuccess={() => { + clearErrors(); + setDone(true); + }} + /> + ) : ( + + )} + +
+ + Previous Step + + {stateAvail.isAvailable ? ( + + Submit + + ) : ( + + )} +
+ + )} +
+ + ); + }} + /> + ); +} + +// ── Step info loader (company manager context) then hands off to employee ctx ─ + +function EmployeeFlowForm({ employmentId }: { employmentId: string }) { + const { hasBankAccount, countryCode, jurisdiction, isLoading } = + useEmployeeFlowContext(employmentId); + + if (isLoading) return

Loading...

; + if (!countryCode) { + return ( +
+

+ Could not determine country for employment{' '} + {employmentId}. Check that the employment exists and your + company-manager token has access. +

+
+ ); + } + + return ( + // Inner context: the proxy mints the employee-scoped JWT-bearer token + // server-side when it sees the x-rf-employment-id header on /v1/employee/* + // routes. The FE never holds the employee token. + + + + ); +} + +// ── Employment ID entry ────────────────────────────────────────────────────── + +function GPEmployeeOnboardingInner() { + const [employmentId, setEmploymentId] = useState(EMPLOYMENT_ID); + const [submitted, setSubmitted] = useState(!!EMPLOYMENT_ID); + + if (!submitted) { + return ( +
+

Employee Self-onboarding

+

+ Enter the employment ID created by the GP Admin flow to begin + self-onboarding. +

+
+

+ Prerequisite: The admin must complete the GP Admin + Onboarding flow and send the invitation first. The + employee endpoints only activate after the invitation is sent. The + federal/state tax steps additionally require the employment to be{' '} + active. +

+
+
+ + setEmploymentId(e.target.value.trim())} + /> +
+
+ +
+
+ ); + } + + return ( +
+

+ Employment:{' '} + {employmentId}{' '} + +

+ +
+ ); +} + +export function PayrollEmployeeOnboardingForm() { + return ( + // Outer context: the proxy mints the company-manager token server-side + // for /v1/employments/* and /v1/companies/*, so the FE never sees one. + + + + ); +} diff --git a/example/src/RemoteFlows.tsx b/example/src/RemoteFlows.tsx index 801b5a9cd..83820f7ff 100644 --- a/example/src/RemoteFlows.tsx +++ b/example/src/RemoteFlows.tsx @@ -46,7 +46,11 @@ type RemoteFlowsProps = Omit & { children: ReactNode; auth?: RemoteFlowsSDKProps['auth']; isClientToken?: boolean; - authType?: 'refresh-token' | 'company-manager' | 'client'; + /** + * `'none'` skips the FE-side auth callback entirely — use it when the proxy + * mints tokens server-side and the FE never needs to hold one. + */ + authType?: 'refresh-token' | 'company-manager' | 'client' | 'none'; }; export const RemoteFlows = ({ @@ -56,6 +60,9 @@ export const RemoteFlows = ({ ...props }: RemoteFlowsProps) => { const auth = useMemo(() => { + if (authType === 'none') { + return undefined; + } if (authType === 'company-manager') { return fetchCompanyManagerToken; } diff --git a/package-lock.json b/package-lock.json index 1888a1d3b..ca4b166d7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -460,12 +460,10 @@ } }, "node_modules/@babel/runtime": { - "version": "7.26.9", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.26.9.tgz", - "integrity": "sha512-aA63XwOkcl4xxQa3HjPMqOP6LiK0ZDv3mUPYEFXkpHbaFjtGggE1A61FjFzJnB+p7/oy2gA8E+rcBNl/zC1tMg==", - "dependencies": { - "regenerator-runtime": "^0.14.0" - }, + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "license": "MIT", "engines": { "node": ">=6.9.0" } @@ -3756,9 +3754,9 @@ "license": "MIT" }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.53.3.tgz", - "integrity": "sha512-mRSi+4cBjrRLoaal2PnqH82Wqyb+d3HsPUN/W+WslCXsZsyHa9ZeQQX/pQsZaVIWDkPcpV6jJ+3KLbTbgnwv8w==", + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.61.0.tgz", + "integrity": "sha512-dnxczajOqt0gesZlN5pGQ1s1imQVrsmCw5G2Ci4oM+0WvNz3pyRnlWrT7McoZIb8VlFwCawdmbWRmxRn7HI+VQ==", "cpu": [ "arm" ], @@ -3770,9 +3768,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.53.3.tgz", - "integrity": "sha512-CbDGaMpdE9sh7sCmTrTUyllhrg65t6SwhjlMJsLr+J8YjFuPmCEjbBSx4Z/e4SmDyH3aB5hGaJUP2ltV/vcs4w==", + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.61.0.tgz", + "integrity": "sha512-Bp3JpGP00Vu3f238ivRrjf7z3xSzVPXqCmaJYA9t2c+c8vKYvOzmXF7LkkeUalTEGd6cZcSWe+PFIP3Vy48fRg==", "cpu": [ "arm64" ], @@ -3784,9 +3782,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.53.3.tgz", - "integrity": "sha512-Nr7SlQeqIBpOV6BHHGZgYBuSdanCXuw09hon14MGOLGmXAFYjx1wNvquVPmpZnl0tLjg25dEdr4IQ6GgyToCUA==", + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.61.0.tgz", + "integrity": "sha512-zaYIpr670mUmmZ1tVzUFplbQbG7h3Gugx3L5FoqhsC2m/YnLlR1a7zVLmXNPy+iY1tFPEbNG+HHBXZGyId0G5w==", "cpu": [ "arm64" ], @@ -3798,9 +3796,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.53.3.tgz", - "integrity": "sha512-DZ8N4CSNfl965CmPktJ8oBnfYr3F8dTTNBQkRlffnUarJ2ohudQD17sZBa097J8xhQ26AwhHJ5mvUyQW8ddTsQ==", + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.61.0.tgz", + "integrity": "sha512-+P49fvkv2dSoeevUW+lgZ/I2JHSsJCK1Lyjj7Cu6E4UHG4tS9XIefzIjo5qhgELjAclnen1rLzK2PMKJdo+Dyg==", "cpu": [ "x64" ], @@ -3812,9 +3810,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.53.3.tgz", - "integrity": "sha512-yMTrCrK92aGyi7GuDNtGn2sNW+Gdb4vErx4t3Gv/Tr+1zRb8ax4z8GWVRfr3Jw8zJWvpGHNpss3vVlbF58DZ4w==", + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.61.0.tgz", + "integrity": "sha512-l3FAAOyKJXH2ea6KNFN+MMgC/rnE94YGLXs2ehYqDcCoHt1DpvgWX75BhUJxN38XojP7Ul+4H8PRn7EdyqSDrw==", "cpu": [ "arm64" ], @@ -3826,9 +3824,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.53.3.tgz", - "integrity": "sha512-lMfF8X7QhdQzseM6XaX0vbno2m3hlyZFhwcndRMw8fbAGUGL3WFMBdK0hbUBIUYcEcMhVLr1SIamDeuLBnXS+Q==", + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.61.0.tgz", + "integrity": "sha512-VokPN3TSctKj65cyCNPaUh4vMFA8awxOot/0sp+4J7ZlNRKQEhXhawqPwajoi8H5ZFt61i0ugZJuTKXBjGJ17Q==", "cpu": [ "x64" ], @@ -3840,13 +3838,16 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.53.3.tgz", - "integrity": "sha512-k9oD15soC/Ln6d2Wv/JOFPzZXIAIFLp6B+i14KhxAfnq76ajt0EhYc5YPeX6W1xJkAdItcVT+JhKl1QZh44/qw==", + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.61.0.tgz", + "integrity": "sha512-DxH0P3wxm+Yzs/p3zrk9dw1rURu8p0Nv5+MRK/L7OtnLNg5rLZraSBFZ8iUXOd9f2BlhJyEpIZUH/emjq4UJ4g==", "cpu": [ "arm" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -3854,13 +3855,16 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.53.3.tgz", - "integrity": "sha512-vTNlKq+N6CK/8UktsrFuc+/7NlEYVxgaEgRXVUVK258Z5ymho29skzW1sutgYjqNnquGwVUObAaxae8rZ6YMhg==", + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.61.0.tgz", + "integrity": "sha512-T6ZvMNe84kAz6TBWHC7hGAoEtzP1LWYw/AqayGWEF6uISt3Abk/st06LqRD9THd7Xz3NxzurUpzAuEAUbZf+nw==", "cpu": [ "arm" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -3868,13 +3872,16 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.53.3.tgz", - "integrity": "sha512-RGrFLWgMhSxRs/EWJMIFM1O5Mzuz3Xy3/mnxJp/5cVhZ2XoCAxJnmNsEyeMJtpK+wu0FJFWz+QF4mjCA7AUQ3w==", + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.61.0.tgz", + "integrity": "sha512-q/4hzvQkDs8b4jIBab1pnLiiM0ayTZsN2amBFPDzuyZxjEd4wDwx0UJFYM3cOZzSf5Kw8fnWSprJzIBMkcR44Q==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -3882,13 +3889,16 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.53.3.tgz", - "integrity": "sha512-kASyvfBEWYPEwe0Qv4nfu6pNkITLTb32p4yTgzFCocHnJLAHs+9LjUu9ONIhvfT/5lv4YS5muBHyuV84epBo/A==", + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.61.0.tgz", + "integrity": "sha512-vvYWX3akdEAY6km+9wAqFDnk6pQsbJKVnj7xawcvs/+fdlYBGp+U+Qq/lLfpIxYIZvZLHMAKD9HLdacSx/r3dw==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -3896,13 +3906,33 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.53.3.tgz", - "integrity": "sha512-JiuKcp2teLJwQ7vkJ95EwESWkNRFJD7TQgYmCnrPtlu50b4XvT5MOmurWNrCj3IFdyjBQ5p9vnrX4JM6I8OE7g==", + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.61.0.tgz", + "integrity": "sha512-DePa5cqOxDP/Zp0VOXpeWaGew5iIv5DXp9NYbzkX5PFQyWVX9184WCTh3hvr/7lhXo8ZVlbFLkz8+o/q1dU6gA==", "cpu": [ "loong64" ], "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.61.0.tgz", + "integrity": "sha512-LV8aWMB8UChglMCEzs7RkN0GsH29RJaLLqwm9fCIjlqwxQTiWAqNcc7wjBkH31hV0PU/yVxGYvrYsgfea2qw6g==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -3910,13 +3940,33 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.53.3.tgz", - "integrity": "sha512-EoGSa8nd6d3T7zLuqdojxC20oBfNT8nexBbB/rkxgKj5T5vhpAQKKnD+h3UkoMuTyXkP5jTjK/ccNRmQrPNDuw==", + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.61.0.tgz", + "integrity": "sha512-QoNSnwQtaeNu5grdBbsL0tt1uyl5EnS8DA8Mr3nluMXbhdQNyhN+G4tBax7VCdxLKj8YJ0/4OO9Ho84jMnJtKA==", "cpu": [ "ppc64" ], "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.61.0.tgz", + "integrity": "sha512-/zZp5MKapIIApE8trN8qLGNSiRN9TUoaUZ1cmVu4XnVdd5LQLOXTtyi+vtfUbNnT3iyjzpPqYeKXmvJ+gJGYWw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -3924,13 +3974,16 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.53.3.tgz", - "integrity": "sha512-4s+Wped2IHXHPnAEbIB0YWBv7SDohqxobiiPA1FIWZpX+w9o2i4LezzH/NkFUl8LRci/8udci6cLq+jJQlh+0g==", + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.61.0.tgz", + "integrity": "sha512-RbrzcD3aJ1k3UbtMRRBNwojdVVyXjuVAFTfn/xPa6EEl6GE9Sm/akPgFTb9aAC9pMKGJ6CtWxaGrqWcabH+ySg==", "cpu": [ "riscv64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -3938,13 +3991,16 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.53.3.tgz", - "integrity": "sha512-68k2g7+0vs2u9CxDt5ktXTngsxOQkSEV/xBbwlqYcUrAVh6P9EgMZvFsnHy4SEiUl46Xf0IObWVbMvPrr2gw8A==", + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.61.0.tgz", + "integrity": "sha512-ZF+onDsBso8PJf1XaG9lB+O9RnBpKGnY6OrzC4CSHrtC1jb6jWLTKK4bRqdoCXHd22gyr2hiYmEAm8Wns/BOCw==", "cpu": [ "riscv64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -3952,13 +4008,16 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.53.3.tgz", - "integrity": "sha512-VYsFMpULAz87ZW6BVYw3I6sWesGpsP9OPcyKe8ofdg9LHxSbRMd7zrVrr5xi/3kMZtpWL/wC+UIJWJYVX5uTKg==", + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.61.0.tgz", + "integrity": "sha512-Atk0aSIk5Zx2Wuh9dgRQgLP0Koc8hOeYpbWryMXyk8G8/HmPkwPPkMqIIDhrXHHYqfUzSJA/I7IWSBv8xSmRBA==", "cpu": [ "s390x" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -3966,13 +4025,16 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.53.3.tgz", - "integrity": "sha512-3EhFi1FU6YL8HTUJZ51imGJWEX//ajQPfqWLI3BQq4TlvHy4X0MOr5q3D2Zof/ka0d5FNdPwZXm3Yyib/UEd+w==", + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.61.0.tgz", + "integrity": "sha512-0uMOcf3eZ5K+K4cYHkdxShFMPlPXCOdfDFEFn9dNYAEEd2cVvmOfH7zFgRVoDgmtQ1m9k5q7qfrHzyMAubKYUA==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -3980,23 +4042,40 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.53.3.tgz", - "integrity": "sha512-eoROhjcc6HbZCJr+tvVT8X4fW3/5g/WkGvvmwz/88sDtSJzO7r/blvoBDgISDiCjDRZmHpwud7h+6Q9JxFwq1Q==", + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.61.0.tgz", + "integrity": "sha512-mvFtE4A/t/7hRJ7X8Ozmu8FsIkAUat2nzl12pgU337BRmq87AQUJztwHz2Zv5/tjo9/C95E66CK03SI/ToEDJw==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ "linux" ] }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.61.0.tgz", + "integrity": "sha512-z9b9+aTxvt8n2rNltMPvyaUfB8NJ+CVyOrGK/MdIKHx7B+lXmZpm/XbRsU7Rpf3fRqJ2uS6mBJiJveCtq8LHDg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.53.3.tgz", - "integrity": "sha512-OueLAWgrNSPGAdUdIjSWXw+u/02BRTcnfw9PN41D2vq/JSEPnJnVuBgw18VkN8wcd4fjUs+jFHVM4t9+kBSNLw==", + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.61.0.tgz", + "integrity": "sha512-jXaXFqKMehsOc+g8R6oo33RRC6w07G9jDBxAE5eAKX7mOcCbZloYIPNhfG9Wl+P9O9IWHFO4OJgPi1Ml2qkt7w==", "cpu": [ "arm64" ], @@ -4008,9 +4087,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.53.3.tgz", - "integrity": "sha512-GOFuKpsxR/whszbF/bzydebLiXIHSgsEUp6M0JI8dWvi+fFa1TD6YQa4aSZHtpmh2/uAlj/Dy+nmby3TJ3pkTw==", + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.61.0.tgz", + "integrity": "sha512-OXNWVFocS2IA4+QplhTZZ2a+8hPZR7T8KuozsNmJKK8y7cp83StHvGksfHzPG3wczWTczyWHVQuqeiTUbjiyBg==", "cpu": [ "arm64" ], @@ -4022,9 +4101,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.53.3.tgz", - "integrity": "sha512-iah+THLcBJdpfZ1TstDFbKNznlzoxa8fmnFYK4V67HvmuNYkVdAywJSoteUszvBQ9/HqN2+9AZghbajMsFT+oA==", + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.61.0.tgz", + "integrity": "sha512-AlAbNtBO637LxSldqV43z0FfXoGfl2TW1DgAg/bs7aQswFbDewz2SJm3BUhiGfbOVtW571xbc9p+REdxhyN/Eg==", "cpu": [ "ia32" ], @@ -4036,9 +4115,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.53.3.tgz", - "integrity": "sha512-J9QDiOIZlZLdcot5NXEepDkstocktoVjkaKUtqzgzpt2yWjGlbYiKyp05rWwk4nypbYUNoFAztEgixoLaSETkg==", + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.61.0.tgz", + "integrity": "sha512-QRSrQXyJ1M4tjNXdR0/G/IgV6lzfQQJYBjlWIEYkY2Xs86DRl/iEpQ4blMDjJxSl7n19eDKKXMg0AmuBVYy8pQ==", "cpu": [ "x64" ], @@ -4050,9 +4129,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.53.3.tgz", - "integrity": "sha512-UhTd8u31dXadv0MopwGgNOBpUVROFKWVQgAg5N1ESyCz8AuBcMqm4AuTjrwgQKGDfoFuz02EuMRHQIw/frmYKQ==", + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.61.0.tgz", + "integrity": "sha512-tkuFxhvKO/HlGd0VsINF6vHSYH8AF8W0TcNxKDK6JZmrehngFj78pToc8iemtnvwilDjs2G/qSzYFhe9U8q+fw==", "cpu": [ "x64" ], @@ -4587,9 +4666,9 @@ "license": "MIT" }, "node_modules/@types/estree": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", "dev": true, "license": "MIT" }, @@ -5018,9 +5097,9 @@ } }, "node_modules/brace-expansion": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", - "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", "dev": true, "license": "MIT", "dependencies": { @@ -6713,14 +6792,16 @@ } }, "node_modules/lodash": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" }, "node_modules/lodash-es": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz", - "integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==" + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz", + "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==", + "license": "MIT" }, "node_modules/lodash.capitalize": { "version": "4.2.1", @@ -6892,9 +6973,10 @@ } }, "node_modules/micromatch/node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "license": "MIT", "engines": { "node": ">=8.6" }, @@ -7840,11 +7922,6 @@ "node": ">=8" } }, - "node_modules/regenerator-runtime": { - "version": "0.14.1", - "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz", - "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==" - }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -7934,13 +8011,13 @@ } }, "node_modules/rollup": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.53.3.tgz", - "integrity": "sha512-w8GmOxZfBmKknvdXU1sdM9NHcoQejwF/4mNgj2JuEEdRaHwwF12K7e9eXn1nLZ07ad+du76mkVsyeb2rKGllsA==", + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.61.0.tgz", + "integrity": "sha512-T9mWdbWfQtp0B5lv/HX+wrhYsmXRlcWnXXmJbXqKJhlRaoS6KMhq0gpyzW4UJfclcxrEdLnTgjT2NjruLONu0g==", "dev": true, "license": "MIT", "dependencies": { - "@types/estree": "1.0.8" + "@types/estree": "1.0.9" }, "bin": { "rollup": "dist/bin/rollup" @@ -7950,28 +8027,31 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.53.3", - "@rollup/rollup-android-arm64": "4.53.3", - "@rollup/rollup-darwin-arm64": "4.53.3", - "@rollup/rollup-darwin-x64": "4.53.3", - "@rollup/rollup-freebsd-arm64": "4.53.3", - "@rollup/rollup-freebsd-x64": "4.53.3", - "@rollup/rollup-linux-arm-gnueabihf": "4.53.3", - "@rollup/rollup-linux-arm-musleabihf": "4.53.3", - "@rollup/rollup-linux-arm64-gnu": "4.53.3", - "@rollup/rollup-linux-arm64-musl": "4.53.3", - "@rollup/rollup-linux-loong64-gnu": "4.53.3", - "@rollup/rollup-linux-ppc64-gnu": "4.53.3", - "@rollup/rollup-linux-riscv64-gnu": "4.53.3", - "@rollup/rollup-linux-riscv64-musl": "4.53.3", - "@rollup/rollup-linux-s390x-gnu": "4.53.3", - "@rollup/rollup-linux-x64-gnu": "4.53.3", - "@rollup/rollup-linux-x64-musl": "4.53.3", - "@rollup/rollup-openharmony-arm64": "4.53.3", - "@rollup/rollup-win32-arm64-msvc": "4.53.3", - "@rollup/rollup-win32-ia32-msvc": "4.53.3", - "@rollup/rollup-win32-x64-gnu": "4.53.3", - "@rollup/rollup-win32-x64-msvc": "4.53.3", + "@rollup/rollup-android-arm-eabi": "4.61.0", + "@rollup/rollup-android-arm64": "4.61.0", + "@rollup/rollup-darwin-arm64": "4.61.0", + "@rollup/rollup-darwin-x64": "4.61.0", + "@rollup/rollup-freebsd-arm64": "4.61.0", + "@rollup/rollup-freebsd-x64": "4.61.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.61.0", + "@rollup/rollup-linux-arm-musleabihf": "4.61.0", + "@rollup/rollup-linux-arm64-gnu": "4.61.0", + "@rollup/rollup-linux-arm64-musl": "4.61.0", + "@rollup/rollup-linux-loong64-gnu": "4.61.0", + "@rollup/rollup-linux-loong64-musl": "4.61.0", + "@rollup/rollup-linux-ppc64-gnu": "4.61.0", + "@rollup/rollup-linux-ppc64-musl": "4.61.0", + "@rollup/rollup-linux-riscv64-gnu": "4.61.0", + "@rollup/rollup-linux-riscv64-musl": "4.61.0", + "@rollup/rollup-linux-s390x-gnu": "4.61.0", + "@rollup/rollup-linux-x64-gnu": "4.61.0", + "@rollup/rollup-linux-x64-musl": "4.61.0", + "@rollup/rollup-openbsd-x64": "4.61.0", + "@rollup/rollup-openharmony-arm64": "4.61.0", + "@rollup/rollup-win32-arm64-msvc": "4.61.0", + "@rollup/rollup-win32-ia32-msvc": "4.61.0", + "@rollup/rollup-win32-x64-gnu": "4.61.0", + "@rollup/rollup-win32-x64-msvc": "4.61.0", "fsevents": "~2.3.2" } }, diff --git a/package.json b/package.json index be320b489..ac065737f 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ }, "scripts": { "audit": "npm audit --audit-level=high", - "build": "NODE_ENV=production tsup", + "build": "NODE_OPTIONS='--max-old-space-size=8192' NODE_ENV=production tsup", "ci": "npm run build && npm run check-format && npm run check-exports && npm run lint && npm run type-check && npm run test", "check-exports": "attw --pack . --ignore-rules=cjs-resolves-to-esm --exclude-entrypoints index.css styles.css --profile node16", "check-format": "oxfmt --check", diff --git a/src/common/createHeadlessForm.tsx b/src/common/createHeadlessForm.tsx index 34f91c4ca..a6f6bd83c 100644 --- a/src/common/createHeadlessForm.tsx +++ b/src/common/createHeadlessForm.tsx @@ -25,7 +25,16 @@ export const createHeadlessForm = ( ): JSONSchemaFormResultWithFieldsets => { if (options && options.jsfModify) { const { required, allOf, ...modifyConfig } = options.jsfModify; - const { schema } = modify(jsfSchema, modifyConfig); + // muteLogging: true suppresses the generic library log; we surface the + // actual warnings ourselves when present so they're actionable. + const { schema, warnings } = modify(jsfSchema, { + ...modifyConfig, + muteLogging: true, + } as Parameters[1]); + if (warnings && warnings.length > 0) { + // eslint-disable-next-line no-console + console.warn('jsfModify warnings:', warnings); + } jsfSchema = schema; if (required) { diff --git a/src/components/form/fields/default/DatePickerFieldDefault.tsx b/src/components/form/fields/default/DatePickerFieldDefault.tsx index a68353e48..247404c89 100644 --- a/src/components/form/fields/default/DatePickerFieldDefault.tsx +++ b/src/components/form/fields/default/DatePickerFieldDefault.tsx @@ -29,6 +29,10 @@ export function DatePickerFieldDefault({ const maxDateValue = maxDate ? new Date(maxDate) : undefined; const [open, setOpen] = useState(false); + const currentYear = new Date().getFullYear(); + const fromYear = minDateValue ? minDateValue.getFullYear() : 1900; + const toYear = maxDateValue ? maxDateValue.getFullYear() : currentYear + 10; + return ( { @@ -69,7 +76,11 @@ export function DatePickerFieldDefault({ field.onChange(formattedDate); setOpen(false); }} - defaultMonth={minDateValue} + defaultMonth={ + field.value + ? new Date(field.value) + : (maxDateValue ?? minDateValue) + } disabled={(date: Date) => { if (minDateValue && date < minDateValue) return true; if (maxDateValue && date > maxDateValue) return true; diff --git a/src/flows/PayrollAdminOnboarding/PayrollAdminOnboardingFlow.tsx b/src/flows/PayrollAdminOnboarding/PayrollAdminOnboardingFlow.tsx index 67f9b1c7b..3002dbc1a 100644 --- a/src/flows/PayrollAdminOnboarding/PayrollAdminOnboardingFlow.tsx +++ b/src/flows/PayrollAdminOnboarding/PayrollAdminOnboardingFlow.tsx @@ -2,15 +2,12 @@ import { useId } from 'react'; import { usePayrollAdminOnboarding } from '@/src/flows/PayrollAdminOnboarding/hooks'; import { PayrollAdminOnboardingContext } from '@/src/flows/PayrollAdminOnboarding/context'; import type { PayrollAdminOnboardingFlowProps } from '@/src/flows/PayrollAdminOnboarding/types'; - -// Stable module-level references — prevents consumer subtrees from unmounting on parent re-renders. -// Each will be replaced with a real implementation in PBYR-4044. -const SelectCountryStep = () => null; -const ContractDetailsStep = () => null; -const AdministrativeDetailsStep = () => null; -const InvitationStep = () => null; -const SubmitButton = () => null; -const BackButton = () => null; +import { SelectCountryStep } from '@/src/flows/PayrollAdminOnboarding/components/SelectCountryStep'; +import { ContractDetailsStep } from '@/src/flows/PayrollAdminOnboarding/components/ContractDetailsStep'; +import { AdministrativeDetailsStep } from '@/src/flows/PayrollAdminOnboarding/components/AdministrativeDetailsStep'; +import { InvitationStep } from '@/src/flows/PayrollAdminOnboarding/components/InvitationStep'; +import { SubmitButton } from '@/src/flows/PayrollAdminOnboarding/components/SubmitButton'; +import { BackButton } from '@/src/flows/PayrollAdminOnboarding/components/BackButton'; export const PayrollAdminOnboardingFlow = ({ companyId, diff --git a/src/flows/PayrollAdminOnboarding/api.ts b/src/flows/PayrollAdminOnboarding/api.ts new file mode 100644 index 000000000..1f50fe1df --- /dev/null +++ b/src/flows/PayrollAdminOnboarding/api.ts @@ -0,0 +1,137 @@ +import { useMutation, useQuery } from '@tanstack/react-query'; +import type { FieldValues } from 'react-hook-form'; +import { + getV1CountriesCountryCodeForm, + postV1Employments, + postV1EmploymentsEmploymentIdInvite, + putV2EmploymentsEmploymentIdAdministrativeDetails, + putV2EmploymentsEmploymentIdContractDetails, +} from '@/src/client'; +import { Client } from '@/src/client/client'; +import { useClient } from '@/src/context'; +import { createHeadlessForm } from '@/src/common/createHeadlessForm'; +import { JSONSchemaFormResultWithFieldsets } from '@/src/flows/types'; + +export type GPAdminSchemaType = + | 'global_payroll_basic_information' + | 'global_payroll_contract_details' + | 'global_payroll_administrative_details'; + +export const useGPFormSchema = ( + countryCode: string | undefined, + schemaType: GPAdminSchemaType, + fieldValues: FieldValues, + queryOptions?: { enabled?: boolean; employmentId?: string }, +): ReturnType> => { + const { client } = useClient(); + const employmentId = queryOptions?.employmentId; + return useQuery({ + queryKey: ['gp-form-schema', countryCode, schemaType, employmentId], + enabled: !!countryCode && (queryOptions?.enabled ?? true), + retry: false, + queryFn: async () => { + const response = await getV1CountriesCountryCodeForm({ + client: client as Client, + headers: { Authorization: `` }, + path: { + country_code: countryCode as string, + form: schemaType, + }, + // Passing employment_id lets the gateway resolve the company/employment + // context (and therefore the schema-engine user_role) for forms whose + // `restrict_fields` component branches on role — contract_details and + // administrative_details are the common cases. The OpenAPI spec marks + // this as required only for `contract_amendment` and + // `global_payroll_state_taxes`, but the gateway uses it more broadly. + ...(employmentId ? { query: { employment_id: employmentId } } : {}), + }); + if (response.error || !response.data) { + throw new Error(`Failed to fetch ${schemaType} schema`); + } + return response; + }, + select: ({ data }) => + createHeadlessForm( + (data?.data as Record) || {}, + fieldValues, + ), + }); +}; + +export const useGPCreateEmployment = () => { + const { client } = useClient(); + return useMutation({ + mutationFn: ({ + countryCode, + legalEntityId, + basicInformation, + externalId, + }: { + countryCode: string; + legalEntityId: string; + basicInformation: Record; + externalId?: string; + }) => + postV1Employments({ + client: client as Client, + headers: { Authorization: `` }, + body: { + type: 'global_payroll_employee', + country_code: countryCode, + engaged_by_entity_slug: legalEntityId, + basic_information: basicInformation, + external_id: externalId, + }, + }), + }); +}; + +export const useGPUpdateContractDetails = () => { + const { client } = useClient(); + return useMutation({ + mutationFn: ({ + employmentId, + contractDetails, + }: { + employmentId: string; + contractDetails: Record; + }) => + putV2EmploymentsEmploymentIdContractDetails({ + client: client as Client, + headers: { Authorization: `` }, + path: { employment_id: employmentId }, + body: { contract_details: contractDetails }, + }), + }); +}; + +export const useGPUpdateAdministrativeDetails = () => { + const { client } = useClient(); + return useMutation({ + mutationFn: ({ + employmentId, + administrativeDetails, + }: { + employmentId: string; + administrativeDetails: Record; + }) => + putV2EmploymentsEmploymentIdAdministrativeDetails({ + client: client as Client, + headers: { Authorization: `` }, + path: { employment_id: employmentId }, + body: { administrative_details: administrativeDetails }, + }), + }); +}; + +export const useGPInviteEmployee = () => { + const { client } = useClient(); + return useMutation({ + mutationFn: ({ employmentId }: { employmentId: string }) => + postV1EmploymentsEmploymentIdInvite({ + client: client as Client, + headers: { Authorization: `` }, + path: { employment_id: employmentId }, + }), + }); +}; diff --git a/src/flows/PayrollAdminOnboarding/components/AdministrativeDetailsStep.tsx b/src/flows/PayrollAdminOnboarding/components/AdministrativeDetailsStep.tsx new file mode 100644 index 000000000..5b3773bd2 --- /dev/null +++ b/src/flows/PayrollAdminOnboarding/components/AdministrativeDetailsStep.tsx @@ -0,0 +1,21 @@ +import { usePayrollAdminOnboardingContext } from '@/src/flows/PayrollAdminOnboarding/context'; +import { PayrollAdminForm } from '@/src/flows/PayrollAdminOnboarding/components/PayrollAdminForm'; +import { useStepSubmitHandler } from '@/src/flows/PayrollAdminOnboarding/components/useStepSubmitHandler'; +import type { GPStepCallbacks } from '@/src/flows/types'; + +export function AdministrativeDetailsStep(props: GPStepCallbacks) { + const { adminBag } = usePayrollAdminOnboardingContext(); + const handleSubmit = useStepSubmitHandler(props); + + return ( + + } + /> + ); +} diff --git a/src/flows/PayrollAdminOnboarding/components/BackButton.tsx b/src/flows/PayrollAdminOnboarding/components/BackButton.tsx new file mode 100644 index 000000000..e0fcc3713 --- /dev/null +++ b/src/flows/PayrollAdminOnboarding/components/BackButton.tsx @@ -0,0 +1,28 @@ +import { ButtonHTMLAttributes, PropsWithChildren } from 'react'; +import { usePayrollAdminOnboardingContext } from '@/src/flows/PayrollAdminOnboarding/context'; +import { useFormFields } from '@/src/context'; + +export function BackButton({ + children, + onClick, + ...props +}: PropsWithChildren>) { + const { adminBag } = usePayrollAdminOnboardingContext(); + const { components } = useFormFields(); + + const CustomButton = components?.button; + if (!CustomButton) { + throw new Error('Button component not found'); + } + + const handleBack = (evt: React.MouseEvent) => { + adminBag.back(); + onClick?.(evt); + }; + + return ( + + {children} + + ); +} diff --git a/src/flows/PayrollAdminOnboarding/components/ContractDetailsStep.tsx b/src/flows/PayrollAdminOnboarding/components/ContractDetailsStep.tsx new file mode 100644 index 000000000..dfeb9bbf5 --- /dev/null +++ b/src/flows/PayrollAdminOnboarding/components/ContractDetailsStep.tsx @@ -0,0 +1,18 @@ +import { usePayrollAdminOnboardingContext } from '@/src/flows/PayrollAdminOnboarding/context'; +import { PayrollAdminForm } from '@/src/flows/PayrollAdminOnboarding/components/PayrollAdminForm'; +import { useStepSubmitHandler } from '@/src/flows/PayrollAdminOnboarding/components/useStepSubmitHandler'; +import type { GPStepCallbacks } from '@/src/flows/types'; + +export function ContractDetailsStep(props: GPStepCallbacks) { + const { adminBag } = usePayrollAdminOnboardingContext(); + const handleSubmit = useStepSubmitHandler(props); + + return ( + + } + /> + ); +} diff --git a/src/flows/PayrollAdminOnboarding/components/InvitationStep.tsx b/src/flows/PayrollAdminOnboarding/components/InvitationStep.tsx new file mode 100644 index 000000000..41808dbbf --- /dev/null +++ b/src/flows/PayrollAdminOnboarding/components/InvitationStep.tsx @@ -0,0 +1,59 @@ +import { PropsWithChildren } from 'react'; +import { usePayrollAdminOnboardingContext } from '@/src/flows/PayrollAdminOnboarding/context'; +import { useFormFields } from '@/src/context'; +import type { GPStepCallbacks } from '@/src/flows/types'; +import { isMutationError, mutationToPromise } from '@/src/lib/mutations'; +import { useGPInviteEmployee } from '@/src/flows/PayrollAdminOnboarding/api'; + +type InvitationStepProps = Pick & { + children?: React.ReactNode; +}; + +export function InvitationStep({ + onSuccess, + onError, + children, +}: PropsWithChildren) { + const { adminBag } = usePayrollAdminOnboardingContext(); + const { components } = useFormFields(); + const inviteMutation = useGPInviteEmployee(); + const { mutateAsyncOrThrow: inviteAsync } = mutationToPromise(inviteMutation); + + const CustomButton = components?.button; + if (!CustomButton) { + throw new Error('Button component not found'); + } + + const handleInvite = async () => { + if (!adminBag.employmentId) return; + try { + const data = await inviteAsync({ employmentId: adminBag.employmentId }); + await adminBag.refetchSteps(); + await onSuccess?.(data); + adminBag.next(); + } catch (error: unknown) { + if (isMutationError(error)) { + onError?.({ + error: error.error, + rawError: error.rawError, + fieldErrors: error.fieldErrors, + }); + } else { + onError?.({ + error: error as Error, + rawError: error as Record, + fieldErrors: [], + }); + } + } + }; + + return ( + + {children ?? 'Send invitation'} + + ); +} diff --git a/src/flows/PayrollAdminOnboarding/components/PayrollAdminForm.tsx b/src/flows/PayrollAdminOnboarding/components/PayrollAdminForm.tsx new file mode 100644 index 000000000..a4eaa61c5 --- /dev/null +++ b/src/flows/PayrollAdminOnboarding/components/PayrollAdminForm.tsx @@ -0,0 +1,38 @@ +import { Form } from '@/src/components/ui/form'; +import { JSONSchemaFormFields } from '@/src/components/form/JSONSchemaForm'; +import { usePayrollAdminOnboardingContext } from '@/src/flows/PayrollAdminOnboarding/context'; +import { useJSONSchemaForm } from '@/src/components/form/useJSONSchemaForm'; + +type PayrollAdminFormProps = { + onSubmit: (values: Record) => Promise; + defaultValues?: Record; +}; + +export function PayrollAdminForm({ + onSubmit, + defaultValues, +}: PayrollAdminFormProps) { + const { formId, adminBag } = usePayrollAdminOnboardingContext(); + + const form = useJSONSchemaForm({ + handleValidation: adminBag.handleValidation, + defaultValues: defaultValues ?? {}, + checkFieldUpdates: adminBag.setFieldValues, + }); + + return ( +
+ + + + + ); +} diff --git a/src/flows/PayrollAdminOnboarding/components/SelectCountryStep.tsx b/src/flows/PayrollAdminOnboarding/components/SelectCountryStep.tsx new file mode 100644 index 000000000..43274688a --- /dev/null +++ b/src/flows/PayrollAdminOnboarding/components/SelectCountryStep.tsx @@ -0,0 +1,91 @@ +import { useQuery } from '@tanstack/react-query'; +import { usePayrollAdminOnboardingContext } from '@/src/flows/PayrollAdminOnboarding/context'; +import { PayrollAdminForm } from '@/src/flows/PayrollAdminOnboarding/components/PayrollAdminForm'; +import type { GPStepCallbacks } from '@/src/flows/types'; +import { useClient } from '@/src/context'; +import { Client } from '@/src/client/client'; +import { countriesOptions } from '@/src/common/api/countries'; +import { isMutationError } from '@/src/lib/mutations'; + +export function SelectCountryStep({ + onSubmit, + onSuccess, + onError, +}: GPStepCallbacks) { + const { adminBag } = usePayrollAdminOnboardingContext(); + const { client } = useClient(); + + const { data: countriesResponse, isLoading: isLoadingCountries } = useQuery( + countriesOptions(client as Client, 'gp-admin'), + ); + + const countryList = countriesResponse?.data?.data ?? []; + + const handleCountryChange = (e: React.ChangeEvent) => { + adminBag.setInternalCountryCode(e.target.value || undefined); + }; + + const handleSubmit = async (values: Record) => { + try { + await onSubmit?.(values); + const data = await adminBag.onSubmit(values); + await onSuccess?.(data); + adminBag.next(); + } catch (error: unknown) { + if (isMutationError(error)) { + onError?.({ + error: error.error, + rawError: error.rawError, + fieldErrors: error.fieldErrors, + }); + } else { + onError?.({ + error: error as Error, + rawError: error as Record, + fieldErrors: [], + }); + } + } + }; + + return ( +
+
+ + +
+ + {adminBag.countryCode && + !adminBag.isLoading && + adminBag.fields.length > 0 && ( + + } + /> + )} +
+ ); +} diff --git a/src/flows/PayrollAdminOnboarding/components/SubmitButton.tsx b/src/flows/PayrollAdminOnboarding/components/SubmitButton.tsx new file mode 100644 index 000000000..a5d37b6d5 --- /dev/null +++ b/src/flows/PayrollAdminOnboarding/components/SubmitButton.tsx @@ -0,0 +1,26 @@ +import { ButtonHTMLAttributes, PropsWithChildren } from 'react'; +import { usePayrollAdminOnboardingContext } from '@/src/flows/PayrollAdminOnboarding/context'; +import { useFormFields } from '@/src/context'; + +export function SubmitButton({ + children, + ...props +}: PropsWithChildren>) { + const { formId, adminBag } = usePayrollAdminOnboardingContext(); + const { components } = useFormFields(); + + const CustomButton = components?.button; + if (!CustomButton) { + throw new Error('Button component not found'); + } + + return ( + + {children} + + ); +} diff --git a/src/flows/PayrollAdminOnboarding/components/useStepSubmitHandler.ts b/src/flows/PayrollAdminOnboarding/components/useStepSubmitHandler.ts new file mode 100644 index 000000000..98c2530fa --- /dev/null +++ b/src/flows/PayrollAdminOnboarding/components/useStepSubmitHandler.ts @@ -0,0 +1,38 @@ +import { usePayrollAdminOnboardingContext } from '@/src/flows/PayrollAdminOnboarding/context'; +import type { GPStepCallbacks } from '@/src/flows/types'; +import { isMutationError } from '@/src/lib/mutations'; + +/** + * Shared submit handler for form-based GP admin steps. + * Calls adminBag.onSubmit, routes errors through isMutationError, then advances step. + */ +export function useStepSubmitHandler({ + onSubmit, + onSuccess, + onError, +}: GPStepCallbacks) { + const { adminBag } = usePayrollAdminOnboardingContext(); + + return async (values: Record) => { + try { + await onSubmit?.(values); + const data = await adminBag.onSubmit(values); + await onSuccess?.(data); + adminBag.next(); + } catch (error: unknown) { + if (isMutationError(error)) { + onError?.({ + error: error.error, + rawError: error.rawError, + fieldErrors: error.fieldErrors, + }); + } else { + onError?.({ + error: error as Error, + rawError: error as Record, + fieldErrors: [], + }); + } + } + }; +} diff --git a/src/flows/PayrollAdminOnboarding/context.ts b/src/flows/PayrollAdminOnboarding/context.ts index 1cb178462..64a64f0b7 100644 --- a/src/flows/PayrollAdminOnboarding/context.ts +++ b/src/flows/PayrollAdminOnboarding/context.ts @@ -16,8 +16,8 @@ export const usePayrollAdminOnboardingContext = () => { 'usePayrollAdminOnboardingContext must be used within a PayrollAdminOnboardingFlow', ); } - return context as { - formId: string; - adminBag: ReturnType; - }; + return { + formId: context.formId, + adminBag: context.adminBag, + } as const; }; diff --git a/src/flows/PayrollAdminOnboarding/hooks.tsx b/src/flows/PayrollAdminOnboarding/hooks.tsx index 8ce5898b0..ab359057c 100644 --- a/src/flows/PayrollAdminOnboarding/hooks.tsx +++ b/src/flows/PayrollAdminOnboarding/hooks.tsx @@ -1,9 +1,19 @@ import { useState, useMemo, useCallback } from 'react'; +import type { FieldValues } from 'react-hook-form'; import { useGPOnboardingSteps } from '@/src/common/api/gpOnboarding'; import { useStepState } from '@/src/flows/useStepState'; import type { Step } from '@/src/flows/useStepState'; import type { PayrollAdminOnboardingFlowProps } from '@/src/flows/PayrollAdminOnboarding/types'; import { useErrorReporting } from '@/src/components/error-handling/useErrorReporting'; +import { mutationToPromise } from '@/src/lib/mutations'; +import { parseJSFToValidate } from '@/src/components/form/utils'; +import { + useGPFormSchema, + useGPCreateEmployment, + useGPUpdateContractDetails, + useGPUpdateAdministrativeDetails, +} from '@/src/flows/PayrollAdminOnboarding/api'; +import type { JSONSchemaFormResultWithFieldsets } from '@/src/flows/types'; export type AdminStepKey = | 'select_country' @@ -46,10 +56,9 @@ export const usePayrollAdminOnboarding = ({ string | undefined >(initialCountryCode); - // Fix: derive from state, not from the prop, so setInternalCountryCode changes are reflected - const skipCountry = !!internalCountryCode; + // Derive from state so setInternalCountryCode is reflected in step visibility + const skipCountry = !!internalCountryCode && !!initialCountryCode; - // Fix: memoize to avoid allocating a new object on every render const steps = useMemo(() => buildAdminSteps(skipCountry), [skipCountry]); const { updateErrorContext } = useErrorReporting({ @@ -63,8 +72,86 @@ export const usePayrollAdminOnboarding = ({ [updateErrorContext], ); - const { stepState, nextStep, previousStep, goToStep, setStepValues } = - useStepState(steps, onStepChange); + const { + stepState, + nextStep, + previousStep, + goToStep, + setStepValues, + fieldValues, + setFieldValues, + } = useStepState(steps, onStepChange); + + const currentStep = stepState.currentStep.name; + + // Schema queries — each enabled only on its own step (or when employment exists for resume) + const basicInfoSchema = useGPFormSchema( + internalCountryCode, + 'global_payroll_basic_information', + fieldValues, + { enabled: currentStep === 'select_country' && !!internalCountryCode }, + ); + + const contractDetailsSchema = useGPFormSchema( + internalCountryCode, + 'global_payroll_contract_details', + fieldValues, + { + enabled: currentStep === 'contract_details' && !!internalCountryCode, + employmentId: internalEmploymentId, + }, + ); + + const adminDetailsSchema = useGPFormSchema( + internalCountryCode, + 'global_payroll_administrative_details', + fieldValues, + { + enabled: + currentStep === 'administrative_details' && !!internalCountryCode, + employmentId: internalEmploymentId, + }, + ); + + const currentSchema = useMemo(() => { + const schemaByStep: Partial< + Record + > = { + select_country: basicInfoSchema.data, + contract_details: contractDetailsSchema.data, + administrative_details: adminDetailsSchema.data, + }; + return schemaByStep[currentStep]; + }, [ + currentStep, + basicInfoSchema.data, + contractDetailsSchema.data, + adminDetailsSchema.data, + ]); + + const isLoadingSchema = + basicInfoSchema.isLoading || + contractDetailsSchema.isLoading || + adminDetailsSchema.isLoading; + + const createEmploymentMutation = useGPCreateEmployment(); + const updateContractDetailsMutation = useGPUpdateContractDetails(); + const updateAdminDetailsMutation = useGPUpdateAdministrativeDetails(); + + const { mutateAsyncOrThrow: createEmploymentAsync } = mutationToPromise( + createEmploymentMutation, + ); + const { mutateAsyncOrThrow: updateContractDetailsAsync } = mutationToPromise( + updateContractDetailsMutation, + ); + const { mutateAsyncOrThrow: updateAdminDetailsAsync } = mutationToPromise( + updateAdminDetailsMutation, + ); + + const isSubmitting = + createEmploymentMutation.isPending || + updateContractDetailsMutation.isPending || + updateAdminDetailsMutation.isPending; const { data: apiSteps, @@ -76,9 +163,91 @@ export const usePayrollAdminOnboarding = ({ apiSteps?.find((s) => s.type === 'completion')?.sub_steps?.[0]?.status === 'completed'; + const handleValidation = useCallback( + async (values: FieldValues) => { + if (!currentSchema) return null; + const parsedValues = await parseJSFToValidate( + values, + currentSchema.fields, + { isPartialValidation: false }, + ); + return currentSchema.handleValidation(parsedValues); + }, + [currentSchema], + ); + + const parseFormValues = useCallback( + async (values: FieldValues): Promise> => { + if (!currentSchema) return values; + return parseJSFToValidate(values, currentSchema.fields, { + isPartialValidation: false, + }); + }, + [currentSchema], + ); + + const onSubmit = useCallback( + async (values: FieldValues) => { + const parsedValues = await parseFormValues(values); + + switch (currentStep) { + case 'select_country': { + if (!internalCountryCode) return; + const data = await createEmploymentAsync({ + countryCode: internalCountryCode, + legalEntityId, + basicInformation: parsedValues, + }); + const empId = (data as { data?: { employment?: { id?: string } } }) + ?.data?.employment?.id; + if (empId) { + setInternalEmploymentId(empId); + await refetchSteps(); + } + return data; + } + + case 'contract_details': { + if (!internalEmploymentId) return; + const data = await updateContractDetailsAsync({ + employmentId: internalEmploymentId, + contractDetails: parsedValues, + }); + await refetchSteps(); + return data; + } + + case 'administrative_details': { + if (!internalEmploymentId) return; + const data = await updateAdminDetailsAsync({ + employmentId: internalEmploymentId, + administrativeDetails: parsedValues, + }); + await refetchSteps(); + return data; + } + + default: + return; + } + }, + [ + currentStep, + internalCountryCode, + internalEmploymentId, + legalEntityId, + parseFormValues, + createEmploymentAsync, + updateContractDetailsAsync, + updateAdminDetailsAsync, + refetchSteps, + ], + ); + return { stepState, - isLoading: isLoadingSteps, + isLoading: isLoadingSteps || isLoadingSchema, + isSubmitting, isComplete: isComplete ?? false, companyId, legalEntityId, @@ -87,11 +256,19 @@ export const usePayrollAdminOnboarding = ({ initialValues, options, apiSteps, - setInternalEmploymentId, - setInternalCountryCode, refetchSteps, - goToNextStep: nextStep, - goToPreviousStep: previousStep, + fields: currentSchema?.fields ?? [], + meta: (currentSchema?.meta ?? + {}) as JSONSchemaFormResultWithFieldsets['meta'], + fieldValues, + setFieldValues, + handleValidation, + parseFormValues, + onSubmit, + setInternalCountryCode, + setInternalEmploymentId, + next: nextStep, + back: previousStep, goToStep, setStepValues, }; diff --git a/src/flows/PayrollAdminOnboarding/index.ts b/src/flows/PayrollAdminOnboarding/index.ts index b58abe6a9..17f027377 100644 --- a/src/flows/PayrollAdminOnboarding/index.ts +++ b/src/flows/PayrollAdminOnboarding/index.ts @@ -3,4 +3,5 @@ export { usePayrollAdminOnboarding } from './hooks'; export type { PayrollAdminOnboardingFlowProps, PayrollAdminOnboardingRenderProps, + GPAdminStepCallbacks, } from './types'; diff --git a/src/flows/PayrollAdminOnboarding/types.ts b/src/flows/PayrollAdminOnboarding/types.ts index 19e8442ab..6027834be 100644 --- a/src/flows/PayrollAdminOnboarding/types.ts +++ b/src/flows/PayrollAdminOnboarding/types.ts @@ -1,19 +1,29 @@ -import { FlowOptions } from '@/src/flows/types'; +import { FlowOptions, GPStepCallbacks } from '@/src/flows/types'; import { usePayrollAdminOnboarding } from '@/src/flows/PayrollAdminOnboarding/hooks'; -// Step component prop types are intentionally empty for this scaffold — PBYR-4044 will -// replace these with typed props once each step component is implemented. -type StepComponentType = React.ComponentType>; +export type { GPStepCallbacks as GPAdminStepCallbacks }; export type PayrollAdminOnboardingRenderProps = { adminBag: ReturnType; components: { - SelectCountryStep: StepComponentType; - ContractDetailsStep: StepComponentType; - AdministrativeDetailsStep: StepComponentType; - InvitationStep: StepComponentType; - SubmitButton: StepComponentType; - BackButton: StepComponentType; + SelectCountryStep: React.ComponentType; + ContractDetailsStep: React.ComponentType; + AdministrativeDetailsStep: React.ComponentType; + InvitationStep: React.ComponentType< + Pick & { + children?: React.ReactNode; + } + >; + SubmitButton: React.ComponentType< + React.ButtonHTMLAttributes & { + children?: React.ReactNode; + } + >; + BackButton: React.ComponentType< + React.ButtonHTMLAttributes & { + children?: React.ReactNode; + } + >; }; }; diff --git a/src/flows/PayrollEmployeeOnboarding/PayrollEmployeeOnboardingFlow.tsx b/src/flows/PayrollEmployeeOnboarding/PayrollEmployeeOnboardingFlow.tsx index 16fae5ed6..8fdb3c697 100644 --- a/src/flows/PayrollEmployeeOnboarding/PayrollEmployeeOnboardingFlow.tsx +++ b/src/flows/PayrollEmployeeOnboarding/PayrollEmployeeOnboardingFlow.tsx @@ -2,17 +2,18 @@ import { useId } from 'react'; import { usePayrollEmployeeOnboarding } from '@/src/flows/PayrollEmployeeOnboarding/hooks'; import { PayrollEmployeeOnboardingContext } from '@/src/flows/PayrollEmployeeOnboarding/context'; import type { PayrollEmployeeOnboardingFlowProps } from '@/src/flows/PayrollEmployeeOnboarding/types'; - -// Stable module-level references — prevents consumer subtrees from unmounting on parent re-renders. -// Each will be replaced with a real implementation in PBYR-4045. -const PersonalDetailsStep = () => null; -const HomeAddressStep = () => null; -const BankAccountStep = () => null; -const SubmitButton = () => null; -const BackButton = () => null; +import { PersonalDetailsStep } from '@/src/flows/PayrollEmployeeOnboarding/components/PersonalDetailsStep'; +import { HomeAddressStep } from '@/src/flows/PayrollEmployeeOnboarding/components/HomeAddressStep'; +import { BankAccountStep } from '@/src/flows/PayrollEmployeeOnboarding/components/BankAccountStep'; +import { FederalTaxesStep } from '@/src/flows/PayrollEmployeeOnboarding/components/FederalTaxesStep'; +import { StateTaxesStep } from '@/src/flows/PayrollEmployeeOnboarding/components/StateTaxesStep'; +import { SubmitButton } from '@/src/flows/PayrollEmployeeOnboarding/components/SubmitButton'; +import { BackButton } from '@/src/flows/PayrollEmployeeOnboarding/components/BackButton'; export const PayrollEmployeeOnboardingFlow = ({ employmentId, + countryCode, + jurisdiction, initialValues, options, render, @@ -20,6 +21,8 @@ export const PayrollEmployeeOnboardingFlow = ({ const formId = useId(); const employeeBag = usePayrollEmployeeOnboarding({ employmentId, + countryCode, + jurisdiction, initialValues, options, }); @@ -32,6 +35,8 @@ export const PayrollEmployeeOnboardingFlow = ({ PersonalDetailsStep, HomeAddressStep, BankAccountStep, + FederalTaxesStep, + StateTaxesStep, SubmitButton, BackButton, }, diff --git a/src/flows/PayrollEmployeeOnboarding/api.ts b/src/flows/PayrollEmployeeOnboarding/api.ts new file mode 100644 index 000000000..b956b4263 --- /dev/null +++ b/src/flows/PayrollEmployeeOnboarding/api.ts @@ -0,0 +1,130 @@ +import { useMutation, useQuery } from '@tanstack/react-query'; +import type { FieldValues } from 'react-hook-form'; +import { + getV1CountriesCountryCodeForm, + putV1EmployeeAddress, + putV1EmployeeBankAccount, + putV1EmployeeFederalTaxes, + putV1EmployeePersonalDetails, + putV1EmployeeStateTaxesJurisdiction, +} from '@/src/client'; +import { Client } from '@/src/client/client'; +import { useClient } from '@/src/context'; +import { createHeadlessForm } from '@/src/common/createHeadlessForm'; +import type { + JSONSchemaFormResultWithFieldsets, + JSFModify, +} from '@/src/flows/types'; + +export type GPEmployeeSchemaType = + | 'global_payroll_personal_details' + | 'address_details' + | 'global_payroll_bank_account_details' + | 'global_payroll_federal_taxes' + | 'global_payroll_state_taxes'; + +export const useGPEmployeeFormSchema = ( + countryCode: string | undefined, + schemaType: GPEmployeeSchemaType, + fieldValues: FieldValues, + queryOptions?: { enabled?: boolean }, + jsfModify?: JSFModify, +): ReturnType> => { + const { client } = useClient(); + return useQuery({ + queryKey: ['gp-employee-form-schema', countryCode, schemaType], + enabled: !!countryCode && (queryOptions?.enabled ?? true), + retry: false, + queryFn: async () => { + const response = await getV1CountriesCountryCodeForm({ + client: client as Client, + headers: { Authorization: `` }, + path: { + country_code: countryCode as string, + form: schemaType, + }, + }); + if (response.error || !response.data) { + throw new Error(`Failed to fetch ${schemaType} schema`); + } + return response; + }, + select: ({ data }) => + createHeadlessForm( + (data?.data as Record) || {}, + fieldValues, + jsfModify ? { jsfModify } : undefined, + ), + }); +}; + +export const useGPUpdatePersonalDetails = () => { + const { client } = useClient(); + return useMutation({ + mutationFn: (personalDetails: Record) => { + // 'name' is a computed read-only display field in the schema (additionalProperties: false + // on the PUT endpoint rejects it). Strip it before sending. + const { name: _name, ...payload } = personalDetails; + return putV1EmployeePersonalDetails({ + client: client as Client, + headers: { Authorization: `` }, + body: { personal_details: payload }, + }); + }, + }); +}; + +export const useGPUpdateHomeAddress = () => { + const { client } = useClient(); + return useMutation({ + mutationFn: (addressDetails: Record) => + putV1EmployeeAddress({ + client: client as Client, + headers: { Authorization: `` }, + body: { address_details: addressDetails }, + }), + }); +}; + +export const useGPUpdateBankAccount = () => { + const { client } = useClient(); + return useMutation({ + mutationFn: (bankAccountDetails: Record) => + putV1EmployeeBankAccount({ + client: client as Client, + headers: { Authorization: `` }, + body: { bank_account_details: bankAccountDetails }, + }), + }); +}; + +export const useGPUpdateFederalTaxes = () => { + const { client } = useClient(); + return useMutation({ + mutationFn: (federalTaxes: Record) => + putV1EmployeeFederalTaxes({ + client: client as Client, + headers: { Authorization: `` }, + body: { federal_taxes: federalTaxes }, + }), + }); +}; + +export const useGPUpdateStateTaxes = (jurisdiction: string | undefined) => { + const { client } = useClient(); + return useMutation({ + mutationFn: (stateTaxes: Record) => { + if (!jurisdiction) { + throw new Error( + 'A `jurisdiction` (US state code) is required to submit state taxes.', + ); + } + return putV1EmployeeStateTaxesJurisdiction({ + client: client as Client, + headers: { Authorization: `` }, + path: { jurisdiction }, + body: { state_taxes: stateTaxes }, + }); + }, + }); +}; diff --git a/src/flows/PayrollEmployeeOnboarding/components/BackButton.tsx b/src/flows/PayrollEmployeeOnboarding/components/BackButton.tsx new file mode 100644 index 000000000..bd4a501b4 --- /dev/null +++ b/src/flows/PayrollEmployeeOnboarding/components/BackButton.tsx @@ -0,0 +1,28 @@ +import { ButtonHTMLAttributes, PropsWithChildren } from 'react'; +import { usePayrollEmployeeOnboardingContext } from '@/src/flows/PayrollEmployeeOnboarding/context'; +import { useFormFields } from '@/src/context'; + +export function BackButton({ + children, + onClick, + ...props +}: PropsWithChildren>) { + const { employeeBag } = usePayrollEmployeeOnboardingContext(); + const { components } = useFormFields(); + + const CustomButton = components?.button; + if (!CustomButton) { + throw new Error('Button component not found'); + } + + const handleBack = (evt: React.MouseEvent) => { + employeeBag.back(); + onClick?.(evt); + }; + + return ( + + {children} + + ); +} diff --git a/src/flows/PayrollEmployeeOnboarding/components/BankAccountStep.tsx b/src/flows/PayrollEmployeeOnboarding/components/BankAccountStep.tsx new file mode 100644 index 000000000..a6c1b553f --- /dev/null +++ b/src/flows/PayrollEmployeeOnboarding/components/BankAccountStep.tsx @@ -0,0 +1,29 @@ +import { usePayrollEmployeeOnboardingContext } from '@/src/flows/PayrollEmployeeOnboarding/context'; +import { PayrollEmployeeForm } from '@/src/flows/PayrollEmployeeOnboarding/components/PayrollEmployeeForm'; +import { useEmployeeStepSubmitHandler } from '@/src/flows/PayrollEmployeeOnboarding/components/useEmployeeStepSubmitHandler'; +import type { GPStepCallbacks } from '@/src/flows/types'; + +/** + * Render only when employeeBag.selfOnboardingSubsteps includes + * 'employee_provides_bank_details'. The step is always present in the step + * state but the substep presence determines whether it is actually required. + */ +export function BankAccountStep(props: GPStepCallbacks) { + const { employeeBag } = usePayrollEmployeeOnboardingContext(); + const handleSubmit = useEmployeeStepSubmitHandler(props); + + const isRequired = employeeBag.selfOnboardingSubsteps.some( + (s) => s.type === 'employee_provides_bank_details', + ); + + if (!isRequired) return null; + + return ( + + } + /> + ); +} diff --git a/src/flows/PayrollEmployeeOnboarding/components/FederalTaxesStep.tsx b/src/flows/PayrollEmployeeOnboarding/components/FederalTaxesStep.tsx new file mode 100644 index 000000000..8736979de --- /dev/null +++ b/src/flows/PayrollEmployeeOnboarding/components/FederalTaxesStep.tsx @@ -0,0 +1,26 @@ +import { usePayrollEmployeeOnboardingContext } from '@/src/flows/PayrollEmployeeOnboarding/context'; +import { PayrollEmployeeForm } from '@/src/flows/PayrollEmployeeOnboarding/components/PayrollEmployeeForm'; +import { useEmployeeStepSubmitHandler } from '@/src/flows/PayrollEmployeeOnboarding/components/useEmployeeStepSubmitHandler'; +import type { GPStepCallbacks } from '@/src/flows/types'; + +/** + * Render only when `employeeBag.taxStepsAvailability.federal_taxes.isAvailable` + * is true. Returns null otherwise so the consumer can render their own + * not-available UI driven by `unavailableReason`. The step also flips itself + * to unavailable retroactively if the backend returns 404 on submit. + */ +export function FederalTaxesStep(props: GPStepCallbacks) { + const { employeeBag } = usePayrollEmployeeOnboardingContext(); + const handleSubmit = useEmployeeStepSubmitHandler(props); + + if (!employeeBag.taxStepsAvailability.federal_taxes.isAvailable) return null; + + return ( + + } + /> + ); +} diff --git a/src/flows/PayrollEmployeeOnboarding/components/HomeAddressStep.tsx b/src/flows/PayrollEmployeeOnboarding/components/HomeAddressStep.tsx new file mode 100644 index 000000000..a1e50078e --- /dev/null +++ b/src/flows/PayrollEmployeeOnboarding/components/HomeAddressStep.tsx @@ -0,0 +1,18 @@ +import { usePayrollEmployeeOnboardingContext } from '@/src/flows/PayrollEmployeeOnboarding/context'; +import { PayrollEmployeeForm } from '@/src/flows/PayrollEmployeeOnboarding/components/PayrollEmployeeForm'; +import { useEmployeeStepSubmitHandler } from '@/src/flows/PayrollEmployeeOnboarding/components/useEmployeeStepSubmitHandler'; +import type { GPStepCallbacks } from '@/src/flows/types'; + +export function HomeAddressStep(props: GPStepCallbacks) { + const { employeeBag } = usePayrollEmployeeOnboardingContext(); + const handleSubmit = useEmployeeStepSubmitHandler(props); + + return ( + + } + /> + ); +} diff --git a/src/flows/PayrollEmployeeOnboarding/components/PayrollEmployeeForm.tsx b/src/flows/PayrollEmployeeOnboarding/components/PayrollEmployeeForm.tsx new file mode 100644 index 000000000..a54b06cc2 --- /dev/null +++ b/src/flows/PayrollEmployeeOnboarding/components/PayrollEmployeeForm.tsx @@ -0,0 +1,39 @@ +import { Form } from '@/src/components/ui/form'; +import { JSONSchemaFormFields } from '@/src/components/form/JSONSchemaForm'; +import { usePayrollEmployeeOnboardingContext } from '@/src/flows/PayrollEmployeeOnboarding/context'; +import { useJSONSchemaForm } from '@/src/components/form/useJSONSchemaForm'; +import type { JSFFieldset } from '@/src/types/remoteFlows'; + +type PayrollEmployeeFormProps = { + onSubmit: (values: Record) => Promise; + defaultValues?: Record; +}; + +export function PayrollEmployeeForm({ + onSubmit, + defaultValues, +}: PayrollEmployeeFormProps) { + const { formId, employeeBag } = usePayrollEmployeeOnboardingContext(); + + const form = useJSONSchemaForm({ + handleValidation: employeeBag.handleValidation, + defaultValues: defaultValues ?? {}, + checkFieldUpdates: employeeBag.setFieldValues, + }); + + return ( +
+ + + + + ); +} diff --git a/src/flows/PayrollEmployeeOnboarding/components/PersonalDetailsStep.tsx b/src/flows/PayrollEmployeeOnboarding/components/PersonalDetailsStep.tsx new file mode 100644 index 000000000..17bef888b --- /dev/null +++ b/src/flows/PayrollEmployeeOnboarding/components/PersonalDetailsStep.tsx @@ -0,0 +1,18 @@ +import { usePayrollEmployeeOnboardingContext } from '@/src/flows/PayrollEmployeeOnboarding/context'; +import { PayrollEmployeeForm } from '@/src/flows/PayrollEmployeeOnboarding/components/PayrollEmployeeForm'; +import { useEmployeeStepSubmitHandler } from '@/src/flows/PayrollEmployeeOnboarding/components/useEmployeeStepSubmitHandler'; +import type { GPStepCallbacks } from '@/src/flows/types'; + +export function PersonalDetailsStep(props: GPStepCallbacks) { + const { employeeBag } = usePayrollEmployeeOnboardingContext(); + const handleSubmit = useEmployeeStepSubmitHandler(props); + + return ( + + } + /> + ); +} diff --git a/src/flows/PayrollEmployeeOnboarding/components/StateTaxesStep.tsx b/src/flows/PayrollEmployeeOnboarding/components/StateTaxesStep.tsx new file mode 100644 index 000000000..8bd2cb532 --- /dev/null +++ b/src/flows/PayrollEmployeeOnboarding/components/StateTaxesStep.tsx @@ -0,0 +1,26 @@ +import { usePayrollEmployeeOnboardingContext } from '@/src/flows/PayrollEmployeeOnboarding/context'; +import { PayrollEmployeeForm } from '@/src/flows/PayrollEmployeeOnboarding/components/PayrollEmployeeForm'; +import { useEmployeeStepSubmitHandler } from '@/src/flows/PayrollEmployeeOnboarding/components/useEmployeeStepSubmitHandler'; +import type { GPStepCallbacks } from '@/src/flows/types'; + +/** + * Render only when `employeeBag.taxStepsAvailability.state_taxes.isAvailable` + * is true (USA + jurisdiction set + post-enrollment). Returns null otherwise. + * Submits to PUT /v1/employee/state-taxes/{jurisdiction} where jurisdiction + * comes from the flow's `jurisdiction` prop. + */ +export function StateTaxesStep(props: GPStepCallbacks) { + const { employeeBag } = usePayrollEmployeeOnboardingContext(); + const handleSubmit = useEmployeeStepSubmitHandler(props); + + if (!employeeBag.taxStepsAvailability.state_taxes.isAvailable) return null; + + return ( + + } + /> + ); +} diff --git a/src/flows/PayrollEmployeeOnboarding/components/SubmitButton.tsx b/src/flows/PayrollEmployeeOnboarding/components/SubmitButton.tsx new file mode 100644 index 000000000..c4f04f61d --- /dev/null +++ b/src/flows/PayrollEmployeeOnboarding/components/SubmitButton.tsx @@ -0,0 +1,26 @@ +import { ButtonHTMLAttributes, PropsWithChildren } from 'react'; +import { usePayrollEmployeeOnboardingContext } from '@/src/flows/PayrollEmployeeOnboarding/context'; +import { useFormFields } from '@/src/context'; + +export function SubmitButton({ + children, + ...props +}: PropsWithChildren>) { + const { formId, employeeBag } = usePayrollEmployeeOnboardingContext(); + const { components } = useFormFields(); + + const CustomButton = components?.button; + if (!CustomButton) { + throw new Error('Button component not found'); + } + + return ( + + {children} + + ); +} diff --git a/src/flows/PayrollEmployeeOnboarding/components/useEmployeeStepSubmitHandler.ts b/src/flows/PayrollEmployeeOnboarding/components/useEmployeeStepSubmitHandler.ts new file mode 100644 index 000000000..27151e857 --- /dev/null +++ b/src/flows/PayrollEmployeeOnboarding/components/useEmployeeStepSubmitHandler.ts @@ -0,0 +1,34 @@ +import { usePayrollEmployeeOnboardingContext } from '@/src/flows/PayrollEmployeeOnboarding/context'; +import type { GPStepCallbacks } from '@/src/flows/types'; +import { isMutationError } from '@/src/lib/mutations'; + +export function useEmployeeStepSubmitHandler({ + onSubmit, + onSuccess, + onError, +}: GPStepCallbacks) { + const { employeeBag } = usePayrollEmployeeOnboardingContext(); + + return async (values: Record) => { + try { + await onSubmit?.(values); + const data = await employeeBag.onSubmit(values); + await onSuccess?.(data); + employeeBag.next(); + } catch (error: unknown) { + if (isMutationError(error)) { + onError?.({ + error: error.error, + rawError: error.rawError, + fieldErrors: error.fieldErrors, + }); + } else { + onError?.({ + error: error as Error, + rawError: error as Record, + fieldErrors: [], + }); + } + } + }; +} diff --git a/src/flows/PayrollEmployeeOnboarding/hooks.tsx b/src/flows/PayrollEmployeeOnboarding/hooks.tsx index e16a0400a..19ca200ce 100644 --- a/src/flows/PayrollEmployeeOnboarding/hooks.tsx +++ b/src/flows/PayrollEmployeeOnboarding/hooks.tsx @@ -1,29 +1,110 @@ -import { useMemo, useCallback } from 'react'; +import { useMemo, useCallback, useState } from 'react'; +import type { FieldValues } from 'react-hook-form'; import { useGPOnboardingSteps } from '@/src/common/api/gpOnboarding'; import { useStepState } from '@/src/flows/useStepState'; import type { Step } from '@/src/flows/useStepState'; -import type { PayrollEmployeeOnboardingFlowProps } from '@/src/flows/PayrollEmployeeOnboarding/types'; +import type { + PayrollEmployeeOnboardingFlowProps, + TaxStepUnavailableReason, +} from '@/src/flows/PayrollEmployeeOnboarding/types'; import { useErrorReporting } from '@/src/components/error-handling/useErrorReporting'; +import { isMutationError, mutationToPromise } from '@/src/lib/mutations'; +import { parseJSFToValidate } from '@/src/components/form/utils'; +import { + useGPEmployeeFormSchema, + useGPUpdateBankAccount, + useGPUpdateFederalTaxes, + useGPUpdateHomeAddress, + useGPUpdatePersonalDetails, + useGPUpdateStateTaxes, +} from '@/src/flows/PayrollEmployeeOnboarding/api'; +import type { + JSONSchemaFormResultWithFieldsets, + JSFModify, +} from '@/src/flows/types'; export type EmployeeStepKey = | 'personal_details' | 'home_address' - | 'bank_account'; + | 'bank_account' + | 'federal_taxes' + | 'state_taxes'; -// Steps are stable — visibility of bank_account is not derived from loaded data here. -// Consumers check selfOnboardingSubsteps to determine whether the bank_account step -// applies to the employment; the BankAccountStep component handles its own no-op case. -const EMPLOYEE_STEPS: Record> = { +// Stable module-level jsfModify configs for personal_details. Two variants: +// - USA: schema's mobile_number is a plain 10-digit string with `inputType: 'text'`. +// Add a description so users know what's expected. +// - non-USA: schema's mobile_number is an `anyOf` of per-country dial-code +// patterns. Force `inputType: 'tel'` so TelField renders a country picker + +// national-number input. Without this it falls back to a plain text input +// that silently fails the anyOf validation. +// +// Both variants hide `name` (a computed display-only field that the PUT +// endpoint rejects via `additionalProperties: false`). +const PERSONAL_DETAILS_HIDE_NAME = { + name: { 'x-jsf-presentation': { inputType: 'hidden' } }, +}; + +const PERSONAL_DETAILS_JSF_MODIFY_USA: JSFModify = { + fields: { + ...PERSONAL_DETAILS_HIDE_NAME, + mobile_number: { + description: 'Enter 10 digits, no country code (e.g. 5389274785)', + }, + }, +}; + +const PERSONAL_DETAILS_JSF_MODIFY_INTL: JSFModify = { + fields: { + ...PERSONAL_DETAILS_HIDE_NAME, + mobile_number: { 'x-jsf-presentation': { inputType: 'tel' } }, + }, +}; + +function getPersonalDetailsJsfModify( + countryCode: string | undefined, +): JSFModify { + return countryCode === 'USA' + ? PERSONAL_DETAILS_JSF_MODIFY_USA + : PERSONAL_DETAILS_JSF_MODIFY_INTL; +} + +const buildEmployeeSteps = ({ + hasBankSubstep, + federalTaxesVisible, + stateTaxesVisible, +}: { + hasBankSubstep: boolean; + federalTaxesVisible: boolean; + stateTaxesVisible: boolean; +}): Record> => ({ personal_details: { index: 0, name: 'personal_details' }, home_address: { index: 1, name: 'home_address' }, - bank_account: { index: 2, name: 'bank_account' }, -}; + bank_account: { index: 2, name: 'bank_account', visible: hasBankSubstep }, + federal_taxes: { + index: 3, + name: 'federal_taxes', + visible: federalTaxesVisible, + }, + state_taxes: { index: 4, name: 'state_taxes', visible: stateTaxesVisible }, +}); + +const TAX_STEPS = ['federal_taxes', 'state_taxes'] as const; +type TaxStepKey = (typeof TAX_STEPS)[number]; export const usePayrollEmployeeOnboarding = ({ employmentId, + countryCode, + jurisdiction, initialValues, options, }: Omit) => { + // Per-step failures detected at submit time. Used to retroactively flip a + // tax step to `pending_enrollment` after the backend returns 404 with + // `Tax task not found...`. + const [taxSubmitFailures, setTaxSubmitFailures] = useState< + Partial> + >({}); + const { updateErrorContext } = useErrorReporting({ flow: 'payroll_employee_onboarding', }); @@ -35,12 +116,11 @@ export const usePayrollEmployeeOnboarding = ({ [updateErrorContext], ); - const { stepState, nextStep, previousStep, goToStep, setStepValues } = - useStepState(EMPLOYEE_STEPS, onStepChange); + // ── API steps ─────────────────────────────────────────────────────────────── const { data: apiSteps, - isLoading, + isLoading: isLoadingSteps, refetch: refetchSteps, } = useGPOnboardingSteps(employmentId); @@ -53,18 +133,330 @@ export const usePayrollEmployeeOnboarding = ({ apiSteps?.find((s) => s.type === 'completion')?.sub_steps?.[0]?.status === 'completed'; + // ── Step visibility — drop steps the backend says are unneeded ────────────── + const isUSA = countryCode === 'USA'; + const isPostEnrollment = isComplete ?? false; + const hasBankSubstep = selfOnboardingSubsteps.some( + (s) => s.type === 'employee_provides_bank_details', + ); + + const steps = useMemo( + () => + buildEmployeeSteps({ + hasBankSubstep, + federalTaxesVisible: isUSA && isPostEnrollment, + stateTaxesVisible: isUSA && !!jurisdiction && isPostEnrollment, + }), + [hasBankSubstep, isUSA, isPostEnrollment, jurisdiction], + ); + + const { + stepState, + nextStep, + previousStep, + goToStep, + setStepValues, + fieldValues, + setFieldValues, + } = useStepState(steps, onStepChange); + + const currentStep = stepState.currentStep.name; + + // ── Tax-step availability ─────────────────────────────────────────────────── + // + // The federal_taxes and state_taxes endpoints only respond once Tiger creates + // the corresponding tax_task — which happens when the employment becomes + // `active`. We don't have a clean signal callable with the employee token + // (employments/:id returns 401, employee/current returns user+company only), + // so we use the `completion` step as the best upfront probe and, when even + // that is insufficient (e.g. step status is completed but employment lifecycle + // is `onboarded`), we fall back to retroactively flipping the step to + // `pending_enrollment` after the PUT returns 404. See `taxSubmitFailures`. + + // ── Schema queries ────────────────────────────────────────────────────────── + + const personalDetailsSchema = useGPEmployeeFormSchema( + countryCode, + 'global_payroll_personal_details', + fieldValues, + { enabled: currentStep === 'personal_details' }, + getPersonalDetailsJsfModify(countryCode), + ); + + const homeAddressSchema = useGPEmployeeFormSchema( + countryCode, + 'address_details', + fieldValues, + { enabled: currentStep === 'home_address' }, + ); + + const bankAccountSchema = useGPEmployeeFormSchema( + countryCode, + 'global_payroll_bank_account_details', + fieldValues, + { enabled: currentStep === 'bank_account' }, + ); + + // The tax-step schema queries are gated only on country + active. We can't + // gate on `taxStepsAvailability` here because availability itself depends on + // the query outcome (schema_unavailable when 400/404), which would create a + // dependency cycle. The query just won't surface in the UI when the step + // isn't current — and a failed fetch flips availability to schema_unavailable + // via the dedicated effect below. + const federalTaxesSchema = useGPEmployeeFormSchema( + countryCode, + 'global_payroll_federal_taxes', + fieldValues, + { + enabled: isUSA && isPostEnrollment && currentStep === 'federal_taxes', + }, + ); + + const stateTaxesSchema = useGPEmployeeFormSchema( + countryCode, + 'global_payroll_state_taxes', + fieldValues, + { + enabled: + isUSA && + !!jurisdiction && + isPostEnrollment && + currentStep === 'state_taxes', + }, + ); + + const currentSchema = useMemo(() => { + const schemaByStep: Partial< + Record + > = { + personal_details: personalDetailsSchema.data, + home_address: homeAddressSchema.data, + bank_account: bankAccountSchema.data, + federal_taxes: federalTaxesSchema.data, + state_taxes: stateTaxesSchema.data, + }; + return schemaByStep[currentStep]; + }, [ + currentStep, + personalDetailsSchema.data, + homeAddressSchema.data, + bankAccountSchema.data, + federalTaxesSchema.data, + stateTaxesSchema.data, + ]); + + // Availability is computed AFTER schema queries so we can fold their error + // state (e.g. backend returns 400 for an unseeded schema) into a friendly + // `schema_unavailable` reason instead of letting the consumer render an + // empty form. + const taxStepsAvailability = useMemo(() => { + const federalReason = ((): TaxStepUnavailableReason | null => { + if (!isUSA) return 'unsupported_country'; + if (taxSubmitFailures.federal_taxes) + return taxSubmitFailures.federal_taxes; + if (!isPostEnrollment) return 'pending_enrollment'; + if (federalTaxesSchema.isError) return 'schema_unavailable'; + return null; + })(); + + const stateReason = ((): TaxStepUnavailableReason | null => { + if (!isUSA) return 'unsupported_country'; + if (!jurisdiction) return 'no_jurisdiction'; + if (taxSubmitFailures.state_taxes) return taxSubmitFailures.state_taxes; + if (!isPostEnrollment) return 'pending_enrollment'; + if (stateTaxesSchema.isError) return 'schema_unavailable'; + return null; + })(); + + return { + federal_taxes: { + isAvailable: federalReason === null, + unavailableReason: federalReason, + }, + state_taxes: { + isAvailable: stateReason === null, + unavailableReason: stateReason, + }, + }; + }, [ + isUSA, + isPostEnrollment, + jurisdiction, + taxSubmitFailures, + federalTaxesSchema.isError, + stateTaxesSchema.isError, + ]); + + const isLoadingSchema = + personalDetailsSchema.isLoading || + homeAddressSchema.isLoading || + bankAccountSchema.isLoading || + federalTaxesSchema.isLoading || + stateTaxesSchema.isLoading; + + // ── Mutations ─────────────────────────────────────────────────────────────── + + const updatePersonalDetailsMutation = useGPUpdatePersonalDetails(); + const updateHomeAddressMutation = useGPUpdateHomeAddress(); + const updateBankAccountMutation = useGPUpdateBankAccount(); + const updateFederalTaxesMutation = useGPUpdateFederalTaxes(); + const updateStateTaxesMutation = useGPUpdateStateTaxes(jurisdiction); + + const { mutateAsyncOrThrow: updatePersonalDetailsAsync } = mutationToPromise( + updatePersonalDetailsMutation, + ); + const { mutateAsyncOrThrow: updateHomeAddressAsync } = mutationToPromise( + updateHomeAddressMutation, + ); + const { mutateAsyncOrThrow: updateBankAccountAsync } = mutationToPromise( + updateBankAccountMutation, + ); + const { mutateAsyncOrThrow: updateFederalTaxesAsync } = mutationToPromise( + updateFederalTaxesMutation, + ); + const { mutateAsyncOrThrow: updateStateTaxesAsync } = mutationToPromise( + updateStateTaxesMutation, + ); + + const isSubmitting = + updatePersonalDetailsMutation.isPending || + updateHomeAddressMutation.isPending || + updateBankAccountMutation.isPending || + updateFederalTaxesMutation.isPending || + updateStateTaxesMutation.isPending; + + // ── Form helpers ──────────────────────────────────────────────────────────── + + const handleValidation = useCallback( + async (values: FieldValues) => { + if (!currentSchema) return null; + const parsedValues = await parseJSFToValidate( + values, + currentSchema.fields, + { isPartialValidation: false }, + ); + return currentSchema.handleValidation(parsedValues); + }, + [currentSchema], + ); + + const parseFormValues = useCallback( + async (values: FieldValues): Promise> => { + if (!currentSchema) return values; + return parseJSFToValidate(values, currentSchema.fields, { + isPartialValidation: false, + }); + }, + [currentSchema], + ); + + /** + * Tiger's tax endpoints return 404 with `{message: "Tax task not found..."}` + * when the employment hasn't reached post-enrollment. Convert that to a + * `pending_enrollment` availability flip so the consumer can render the + * not-available state instead of surfacing a raw error. + */ + const handleTaxSubmitError = useCallback( + (taxStep: TaxStepKey, error: unknown) => { + if (!isMutationError(error)) return; + const status = error.response?.status; + const message = + typeof error.rawError === 'object' && + error.rawError !== null && + 'message' in error.rawError + ? String((error.rawError as { message?: unknown }).message ?? '') + : ''; + if (status === 404 || /tax task not found/i.test(message)) { + setTaxSubmitFailures((prev) => ({ + ...prev, + [taxStep]: 'pending_enrollment' as TaxStepUnavailableReason, + })); + } + }, + [], + ); + + const onSubmit = useCallback( + async (values: FieldValues) => { + const parsedValues = await parseFormValues(values); + + switch (currentStep) { + case 'personal_details': { + const data = await updatePersonalDetailsAsync(parsedValues); + await refetchSteps(); + return data; + } + case 'home_address': { + const data = await updateHomeAddressAsync(parsedValues); + await refetchSteps(); + return data; + } + case 'bank_account': { + const data = await updateBankAccountAsync(parsedValues); + await refetchSteps(); + return data; + } + case 'federal_taxes': { + try { + const data = await updateFederalTaxesAsync(parsedValues); + await refetchSteps(); + return data; + } catch (e) { + handleTaxSubmitError('federal_taxes', e); + throw e; + } + } + case 'state_taxes': { + try { + const data = await updateStateTaxesAsync(parsedValues); + await refetchSteps(); + return data; + } catch (e) { + handleTaxSubmitError('state_taxes', e); + throw e; + } + } + default: + return; + } + }, + [ + currentStep, + parseFormValues, + updatePersonalDetailsAsync, + updateHomeAddressAsync, + updateBankAccountAsync, + updateFederalTaxesAsync, + updateStateTaxesAsync, + refetchSteps, + handleTaxSubmitError, + ], + ); + return { stepState, - isLoading, + isLoading: isLoadingSteps || isLoadingSchema, + isSubmitting, isComplete: isComplete ?? false, employmentId, + countryCode, + jurisdiction, initialValues, options, apiSteps, selfOnboardingSubsteps, + taxStepsAvailability, refetchSteps, - goToNextStep: nextStep, - goToPreviousStep: previousStep, + fields: currentSchema?.fields ?? [], + meta: (currentSchema?.meta ?? + {}) as JSONSchemaFormResultWithFieldsets['meta'], + fieldValues, + setFieldValues, + handleValidation, + parseFormValues, + onSubmit, + next: nextStep, + back: previousStep, goToStep, setStepValues, }; diff --git a/src/flows/PayrollEmployeeOnboarding/index.ts b/src/flows/PayrollEmployeeOnboarding/index.ts index af97f5cff..4f140a07d 100644 --- a/src/flows/PayrollEmployeeOnboarding/index.ts +++ b/src/flows/PayrollEmployeeOnboarding/index.ts @@ -3,4 +3,6 @@ export { usePayrollEmployeeOnboarding } from './hooks'; export type { PayrollEmployeeOnboardingFlowProps, PayrollEmployeeOnboardingRenderProps, + GPEmployeeStepCallbacks, + TaxStepUnavailableReason, } from './types'; diff --git a/src/flows/PayrollEmployeeOnboarding/tests/components.test.tsx b/src/flows/PayrollEmployeeOnboarding/tests/components.test.tsx new file mode 100644 index 000000000..dd08e71eb --- /dev/null +++ b/src/flows/PayrollEmployeeOnboarding/tests/components.test.tsx @@ -0,0 +1,299 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { renderHook } from '@testing-library/react'; +import { BackButton } from '../components/BackButton'; +import { BankAccountStep } from '../components/BankAccountStep'; +import { FederalTaxesStep } from '../components/FederalTaxesStep'; +import { HomeAddressStep } from '../components/HomeAddressStep'; +import { PersonalDetailsStep } from '../components/PersonalDetailsStep'; +import { StateTaxesStep } from '../components/StateTaxesStep'; +import { SubmitButton } from '../components/SubmitButton'; +import { useEmployeeStepSubmitHandler } from '../components/useEmployeeStepSubmitHandler'; +import { usePayrollEmployeeOnboardingContext } from '../context'; +import { useFormFields } from '@/src/context'; +import { ButtonDefault } from '@/src/components/form/fields/default/ButtonDefault'; + +vi.mock('../context'); +vi.mock('@/src/context', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, useFormFields: vi.fn() }; +}); +// Stub the form renderer — its real implementation pulls in react-hook-form +// resolvers we don't need to exercise here; the step components' branch +// behaviour is what we're after. +vi.mock('../components/PayrollEmployeeForm', () => ({ + PayrollEmployeeForm: (props: { defaultValues?: Record }) => ( +
+ ), +})); + +const mockUseCtx = vi.mocked(usePayrollEmployeeOnboardingContext); +const mockUseFormFields = vi.mocked(useFormFields); + +type Bag = ReturnType< + typeof usePayrollEmployeeOnboardingContext +>['employeeBag']; + +function buildBag(overrides: Partial = {}): Bag { + return { + isSubmitting: false, + selfOnboardingSubsteps: [], + initialValues: undefined, + back: vi.fn(), + next: vi.fn(), + onSubmit: vi.fn().mockResolvedValue({ data: 'ok' }), + taxStepsAvailability: { + federal_taxes: { isAvailable: true, unavailableReason: null }, + state_taxes: { isAvailable: true, unavailableReason: null }, + }, + ...overrides, + } as unknown as Bag; +} + +function mockContext(bag: Bag, formId = 'form-id') { + mockUseCtx.mockReturnValue({ formId, employeeBag: bag }); +} + +beforeEach(() => { + mockUseFormFields.mockReturnValue({ components: { button: ButtonDefault } }); +}); + +afterEach(() => { + vi.clearAllMocks(); +}); + +describe('SubmitButton', () => { + it('forwards form id and disables when bag.isSubmitting is true', () => { + mockContext(buildBag({ isSubmitting: true })); + render(Save); + const btn = screen.getByRole('button', { name: 'Save' }); + expect(btn).toBeDisabled(); + expect(btn).toHaveAttribute('form', 'form-id'); + }); + + it('respects explicit disabled prop when isSubmitting is false', () => { + mockContext(buildBag({ isSubmitting: false })); + render(Save); + expect(screen.getByRole('button')).toBeDisabled(); + }); + + it('throws when no button component is configured', () => { + mockContext(buildBag()); + mockUseFormFields.mockReturnValue({ + components: {} as ReturnType['components'], + }); + expect(() => render(Save)).toThrow( + /Button component not found/, + ); + }); +}); + +describe('BackButton', () => { + it('calls back then the consumer onClick on click', () => { + const back = vi.fn(); + const onClick = vi.fn(); + mockContext(buildBag({ back })); + render(Back); + fireEvent.click(screen.getByRole('button', { name: 'Back' })); + expect(back).toHaveBeenCalledTimes(1); + expect(onClick).toHaveBeenCalledTimes(1); + }); + + it('still calls back when no onClick is supplied', () => { + const back = vi.fn(); + mockContext(buildBag({ back })); + render(Back); + fireEvent.click(screen.getByRole('button')); + expect(back).toHaveBeenCalledTimes(1); + }); + + it('throws when no button component is configured', () => { + mockContext(buildBag()); + mockUseFormFields.mockReturnValue({ + components: {} as ReturnType['components'], + }); + expect(() => render(Back)).toThrow( + /Button component not found/, + ); + }); +}); + +describe('PersonalDetailsStep / HomeAddressStep', () => { + it('renders the form with personal_details initialValues', () => { + mockContext( + buildBag({ + initialValues: { personal_details: { given_name: 'A' } }, + }), + ); + render(); + expect(screen.getByTestId('payroll-employee-form')).toHaveAttribute( + 'data-default', + JSON.stringify({ given_name: 'A' }), + ); + }); + + it('renders the form with home_address initialValues', () => { + mockContext( + buildBag({ initialValues: { home_address: { city: 'Lagos' } } }), + ); + render(); + expect(screen.getByTestId('payroll-employee-form')).toHaveAttribute( + 'data-default', + JSON.stringify({ city: 'Lagos' }), + ); + }); +}); + +describe('BankAccountStep', () => { + it('returns null when bank substep is NOT required', () => { + mockContext(buildBag({ selfOnboardingSubsteps: [] })); + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); + + it('renders the form when employee_provides_bank_details is in substeps', () => { + mockContext( + buildBag({ + selfOnboardingSubsteps: [ + { + id: 'employee_provides_bank_details', + type: 'employee_provides_bank_details', + label: 'Bank account', + status: 'not_started', + optional: false, + }, + ], + initialValues: { bank_account: { iban: 'XX' } }, + }), + ); + render(); + expect(screen.getByTestId('payroll-employee-form')).toHaveAttribute( + 'data-default', + JSON.stringify({ iban: 'XX' }), + ); + }); +}); + +describe('FederalTaxesStep / StateTaxesStep', () => { + it('FederalTaxesStep returns null when not available', () => { + mockContext( + buildBag({ + taxStepsAvailability: { + federal_taxes: { + isAvailable: false, + unavailableReason: 'pending_enrollment', + }, + state_taxes: { isAvailable: true, unavailableReason: null }, + }, + }), + ); + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); + + it('FederalTaxesStep renders the form when available', () => { + mockContext( + buildBag({ + initialValues: { federal_taxes: { filing_status: 'single' } }, + }), + ); + render(); + expect(screen.getByTestId('payroll-employee-form')).toHaveAttribute( + 'data-default', + JSON.stringify({ filing_status: 'single' }), + ); + }); + + it('StateTaxesStep returns null when not available (e.g. no_jurisdiction)', () => { + mockContext( + buildBag({ + taxStepsAvailability: { + federal_taxes: { isAvailable: true, unavailableReason: null }, + state_taxes: { + isAvailable: false, + unavailableReason: 'no_jurisdiction', + }, + }, + }), + ); + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); + + it('StateTaxesStep renders the form when available', () => { + mockContext( + buildBag({ + initialValues: { state_taxes: { filing_status: 'single' } }, + }), + ); + render(); + expect(screen.getByTestId('payroll-employee-form')).toHaveAttribute( + 'data-default', + JSON.stringify({ filing_status: 'single' }), + ); + }); +}); + +describe('useEmployeeStepSubmitHandler', () => { + it('calls onSubmit, bag.onSubmit, onSuccess, then advances', async () => { + const next = vi.fn(); + const bagOnSubmit = vi.fn().mockResolvedValue({ id: '1' }); + mockContext(buildBag({ onSubmit: bagOnSubmit, next })); + + const onSubmit = vi.fn(); + const onSuccess = vi.fn(); + const onError = vi.fn(); + const { result } = renderHook(() => + useEmployeeStepSubmitHandler({ onSubmit, onSuccess, onError }), + ); + await result.current({ a: 1 }); + expect(onSubmit).toHaveBeenCalledWith({ a: 1 }); + expect(bagOnSubmit).toHaveBeenCalledWith({ a: 1 }); + expect(onSuccess).toHaveBeenCalledWith({ id: '1' }); + expect(next).toHaveBeenCalledTimes(1); + expect(onError).not.toHaveBeenCalled(); + }); + + it('routes mutation-shaped errors through onError without advancing', async () => { + const next = vi.fn(); + const mutationErr = { + error: new Error('boom'), + rawError: { message: 'boom' }, + normalizedErrors: {}, + fieldErrors: [{ field: 'x', messages: ['bad'] }], + }; + const bagOnSubmit = vi.fn().mockRejectedValue(mutationErr); + mockContext(buildBag({ onSubmit: bagOnSubmit, next })); + + const onError = vi.fn(); + const { result } = renderHook(() => + useEmployeeStepSubmitHandler({ onError }), + ); + await result.current({}); + expect(onError).toHaveBeenCalledWith({ + error: mutationErr.error, + rawError: mutationErr.rawError, + fieldErrors: mutationErr.fieldErrors, + }); + expect(next).not.toHaveBeenCalled(); + }); + + it('falls back to plain-error shape when error is not a MutationError', async () => { + const plainErr = new Error('plain'); + const bagOnSubmit = vi.fn().mockRejectedValue(plainErr); + mockContext(buildBag({ onSubmit: bagOnSubmit })); + + const onError = vi.fn(); + const { result } = renderHook(() => + useEmployeeStepSubmitHandler({ onError }), + ); + await result.current({}); + expect(onError).toHaveBeenCalledWith({ + error: plainErr, + rawError: plainErr, + fieldErrors: [], + }); + }); +}); diff --git a/src/flows/PayrollEmployeeOnboarding/types.ts b/src/flows/PayrollEmployeeOnboarding/types.ts index 12359e23b..863ef5732 100644 --- a/src/flows/PayrollEmployeeOnboarding/types.ts +++ b/src/flows/PayrollEmployeeOnboarding/types.ts @@ -1,25 +1,69 @@ -import { FlowOptions } from '@/src/flows/types'; +import { FlowOptions, GPStepCallbacks } from '@/src/flows/types'; import { usePayrollEmployeeOnboarding } from '@/src/flows/PayrollEmployeeOnboarding/hooks'; -// Step component prop types are intentionally empty for this scaffold — PBYR-4045 will -// replace these with typed props once each step component is implemented. -type StepComponentType = React.ComponentType>; +export type { GPStepCallbacks as GPEmployeeStepCallbacks }; + +type StepComponentType = React.ComponentType; + +/** + * Reasons a tax step (federal or state) is unavailable to the employee right now. + * + * - `unsupported_country`: only USA employments expose the tax steps. + * - `pending_enrollment`: the employment is not yet `active`, so the + * corresponding tax_task does not exist on the backend yet (PUT returns 404 + * with `Tax task not found...`). + * - `no_jurisdiction`: a `jurisdiction` prop was not supplied to the flow, + * which is required for the state-taxes endpoint. + * - `schema_unavailable`: the backend doesn't expose the form schema for this + * step (e.g. `GET /v1/countries/USA/global_payroll_state_taxes` returns 400 + * or 404). Common on local/staging backends where a schema isn't seeded yet. + */ +export type TaxStepUnavailableReason = + | 'unsupported_country' + | 'pending_enrollment' + | 'no_jurisdiction' + | 'schema_unavailable'; export type PayrollEmployeeOnboardingRenderProps = { employeeBag: ReturnType; components: { PersonalDetailsStep: StepComponentType; HomeAddressStep: StepComponentType; - /** Check employeeBag.selfOnboardingSubsteps to determine if bank account is required. */ + /** Check employeeBag.selfOnboardingSubsteps for 'employee_provides_bank_details' before rendering. */ BankAccountStep: StepComponentType; - SubmitButton: StepComponentType; - BackButton: StepComponentType; + /** + * USA W-4 step. Returns null when `employeeBag.taxStepsAvailability.federal_taxes.isAvailable` + * is false — read the bag to render your own not-available UI. + */ + FederalTaxesStep: StepComponentType; + /** + * USA state-taxes step for a single jurisdiction (`PayrollEmployeeOnboardingFlowProps.jurisdiction`). + * Returns null when `employeeBag.taxStepsAvailability.state_taxes.isAvailable` is false. + */ + StateTaxesStep: StepComponentType; + SubmitButton: React.ComponentType< + React.ButtonHTMLAttributes & { + children?: React.ReactNode; + } + >; + BackButton: React.ComponentType< + React.ButtonHTMLAttributes & { + children?: React.ReactNode; + } + >; }; }; export type PayrollEmployeeOnboardingFlowProps = { /** UUID of the employment, scoped to the employee token. */ employmentId: string; + /** ISO 3166-1 alpha-3 country code of the employment (e.g. 'GBR'). Required for form schema fetching. */ + countryCode: string; + /** + * Optional US state code (e.g. 'CA', 'NY'). Required for the state_taxes step + * to be rendered; omit it for non-USA employments or to skip state taxes entirely. + */ + jurisdiction?: string; /** Optional. Pre-populate form fields. */ initialValues?: Record; options?: Omit; diff --git a/src/flows/types.ts b/src/flows/types.ts index c277190b8..8c556ac1e 100644 --- a/src/flows/types.ts +++ b/src/flows/types.ts @@ -4,6 +4,7 @@ import type { FormResult as FormResultNext, FormResultLegacy, } from '@remoteoss/remote-json-schema-form-kit'; +import type { FieldError } from '@/src/lib/mutations'; type Success = { data: T; @@ -112,3 +113,18 @@ export type JSONSchemaFormResultWithFieldsets = FormResult & { 'x-jsf-presentation'?: Record; }; }; + +/** + * Shared callback prop type for GP step components (admin and employee flows). + * `TSuccess` lets a step narrow the success payload to its own response type; + * defaults to `unknown` for compatibility. + */ +export type GPStepCallbacks = { + onSubmit?: (payload: Record) => void | Promise; + onSuccess?: (data: TSuccess) => void | Promise; + onError?: (args: { + error: Error; + rawError: Record; + fieldErrors: FieldError[]; + }) => void; +}; diff --git a/src/index.tsx b/src/index.tsx index 6ef37e3e1..0b4edf00f 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -103,9 +103,14 @@ export { export type { PayrollEmployeeOnboardingFlowProps, PayrollEmployeeOnboardingRenderProps, + GPEmployeeStepCallbacks, + TaxStepUnavailableReason, } from '@/src/flows/PayrollEmployeeOnboarding'; -export { useGPLegalEntities } from '@/src/common/api/gpOnboarding'; +export { + useGPLegalEntities, + useGPOnboardingSteps, +} from '@/src/common/api/gpOnboarding'; export { CreateCompanyFlow } from '@/src/flows/CreateCompany'; export type { diff --git a/src/styles/global.css b/src/styles/global.css index c6c22c3f9..f25e8567f 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -225,4 +225,40 @@ height: 0.875rem; width: 0.875rem; } + + /* Caption dropdown layout (captionLayout="dropdown") — month/year selects */ + .rdp-caption_dropdowns { + display: flex; + align-items: center; + gap: 0.25rem; + } + + .rdp-dropdown { + appearance: none; + -webkit-appearance: none; + background: transparent; + border: 1px solid var(--color-border); + border-radius: 0.375rem; + font-size: 0.875rem; + font-weight: 500; + padding: 0.15rem 1.5rem 0.15rem 0.5rem; + cursor: pointer; + color: var(--color-foreground); + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%2371717a' stroke-width='2'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 0.35rem center; + } + + .rdp-dropdown:focus { + outline: 2px solid var(--color-ring); + outline-offset: 1px; + } + + .rdp-dropdown_month { + min-width: 5rem; + } + + .rdp-dropdown_year { + min-width: 4rem; + } }