feat: add poa and poa delegation file to onboarding

This commit is contained in:
Nathnael
2026-07-10 08:18:37 +00:00
parent e5d2bb2f63
commit 78cb1ac5d3
7 changed files with 256 additions and 18 deletions

View File

@@ -392,11 +392,17 @@ export default function OnboardingWizardDialog({
const requiredDocsMissing = requirementDocuments.some(
(d) => d.isRequired && !d.uploaded,
);
// The PoA gets the same treatment: a resumed draft that predates the
// delegation-letter requirement (or a forwarder whose PoA is blank) must land
// back on the PoA step, where both the details and the letter are entered.
const poaIncomplete = requirementsQuery.data?.poa?.complete === false;
// Each unmet requirement lowers the ceiling; resume never moves forward.
let ceiling = FORM_STEPS.length - 1;
if (requiredDocsMissing)
ceiling = Math.min(ceiling, FORM_STEPS.indexOf("documents"));
if (poaIncomplete) ceiling = Math.min(ceiling, FORM_STEPS.indexOf("poa"));
const effectiveResumeStep: FormStep =
requiredDocsMissing &&
FORM_STEPS.indexOf(resumeFormStep) > FORM_STEPS.indexOf("documents")
? "documents"
: resumeFormStep;
FORM_STEPS[Math.min(Math.max(FORM_STEPS.indexOf(resumeFormStep), 0), ceiling)];
const formProps = {
documentSettingCode: resolvedDocumentSettingCode,

View File

@@ -12,7 +12,7 @@ import {
import { zodResolver } from "@hookform/resolvers/zod";
import { useQuery } from "@tanstack/react-query";
import { AlertCircle, ArrowLeft, ArrowRight } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { useForm } from "react-hook-form";
import type { AuthUser } from "@/types/auth";
@@ -28,9 +28,11 @@ import RoleLicenseStep, {
} from "@/components/onboarding/RoleLicenseStep";
import ETradeInfo from "@/components/onboarding/ETradeInfo";
import {
buildOnboardingSchema,
type CompanyStep,
type FormData,
onboardingSchema,
hasPoaDetails,
POA_DELEGATION_FILE_KEY,
stepFields,
} from "./companyProfileForm/schema";
import {
@@ -155,6 +157,12 @@ export default function CompanyProfileForm({
}),
);
// A freight forwarder signs on other companies' behalf, so its Power of
// Attorney (details + delegation letter) is mandatory rather than optional.
const requirePoa = (roleProfiles ?? []).some(
(p) => p.type === "freight_forwarder",
);
const {
register,
control,
@@ -164,7 +172,7 @@ export default function CompanyProfileForm({
setValue,
formState: { errors },
} = useForm<FormData>({
resolver: zodResolver(onboardingSchema),
resolver: zodResolver(buildOnboardingSchema(requirePoa)),
defaultValues: {
companyName: "",
companyEmail: "",
@@ -354,7 +362,34 @@ export default function CompanyProfileForm({
}
};
const hasDocuments = Boolean(uploadSetting?.fields?.length);
// The delegation letter is seeded into the same nationality document set as
// the rest, but belongs on the PoA step next to the details it evidences —
// so it's split out here and the Documents step renders the remainder. Both
// halves share `documentFiles`, so the existing bulk upload still carries it.
const poaDocumentField = uploadSetting?.fields?.find(
(f) => f.fileKey === POA_DELEGATION_FILE_KEY,
);
const documentsSetting = useMemo(
() =>
uploadSetting
? {
...uploadSetting,
fields: uploadSetting.fields.filter(
(f) => f.fileKey !== POA_DELEGATION_FILE_KEY,
),
}
: undefined,
[uploadSetting],
);
const poaDocumentSetting = useMemo(
() =>
uploadSetting && poaDocumentField
? { ...uploadSetting, fields: [poaDocumentField] }
: undefined,
[uploadSetting, poaDocumentField],
);
const hasDocuments = Boolean(documentsSetting?.fields?.length);
// Hard verification for the documents step: required company-level
// documents and a business license per operational profile must both be
@@ -368,7 +403,7 @@ export default function CompanyProfileForm({
const validateRequiredDocuments = (): Record<string, string> => {
const errs: Record<string, string> = {};
for (const field of uploadSetting?.fields ?? []) {
for (const field of documentsSetting?.fields ?? []) {
const min = getMinFiles(field);
if (min <= 0) continue;
if ((uploadedDocumentKeys ?? []).includes(field.fileKey)) continue;
@@ -447,6 +482,20 @@ export default function CompanyProfileForm({
];
const currentIdx = stepOrder.indexOf(step);
// The delegation letter is what proves the representative was actually
// delegated, so it's required the moment a PoA exists — and unconditionally
// for a freight forwarder, whose PoA itself is mandatory. Skipped entirely
// when the document set predates the field (seeder not yet re-run).
const poaProvided = hasPoaDetails(watch());
const delegationRequired =
Boolean(poaDocumentField) && (requirePoa || poaProvided);
const delegationPresent =
(uploadedDocumentKeys ?? []).includes(POA_DELEGATION_FILE_KEY) ||
(() => {
const v = documentFiles[POA_DELEGATION_FILE_KEY];
return Array.isArray(v) ? v.length > 0 : v != null;
})();
/** Validate + persist the current step, returning whether we may advance. */
const saveCurrentStep = async (): Promise<boolean> => {
setSaveError(null);
@@ -498,6 +547,20 @@ export default function CompanyProfileForm({
handleSubmit((data) => onSubmit(buildPayload(data, user)))();
return;
}
// The PoA step also gates on a file, which lives outside the form state.
if (step === "poa" && delegationRequired && !delegationPresent) {
setDocumentFieldErrors({
[POA_DELEGATION_FILE_KEY]: "Delegation letter is required",
});
setSaveError(
requirePoa
? "Freight forwarders must provide Power of Attorney details and a delegation letter."
: "Upload the delegation letter for the Power of Attorney you entered, or clear the PoA details to skip.",
);
// Fall through to validate the text fields too, so every problem shows at once.
await trigger(stepFields.poa);
return;
}
// Field steps validate + save before advancing.
const ok = await saveCurrentStep();
if (!ok) return;
@@ -743,8 +806,9 @@ export default function CompanyProfileForm({
{step === "poa" && (
<>
<Text size="sm" c="edr-muted">
Power of Attorney details are optional. Fill them in if you have
them, or skip to continue.
{requirePoa
? "As a freight forwarder you act on other companies' behalf, so Power of Attorney details and a delegation letter are required."
: "Power of Attorney details are optional. Fill them in if you have them, or skip to continue. If you do enter a representative, upload the delegation letter authorising them."}
</Text>
{watch("contactPersonName") && (
<LinkCheckboxCard
@@ -788,6 +852,19 @@ export default function CompanyProfileForm({
{...register("poaAddress")}
/>
</SimpleGrid>
{poaDocumentSetting && (
<>
<Divider my="sm" />
<SmartFileInput
file={poaDocumentSetting}
value={documentFiles}
uploadedKeys={uploadedDocumentKeys}
errors={documentFieldErrors}
onChange={handleDocumentFilesChange}
/>
</>
)}
</>
)}
@@ -797,13 +874,13 @@ export default function CompanyProfileForm({
<Group justify="center" py="xl">
<Loader size="sm" color="edr-green" />
</Group>
) : !uploadSetting ? (
) : !documentsSetting ? (
<Text size="sm" c="edr-muted" ta="center" py="md">
No document requirements found for your account type.
</Text>
) : (
<SmartFileInput
file={uploadSetting}
file={documentsSetting}
value={documentFiles}
uploadedKeys={uploadedDocumentKeys}
errors={documentFieldErrors}

View File

@@ -63,12 +63,56 @@ export const onboardingSchema = z.object({
.optional()
.refine((v) => !v || isValidPhone(v), "Enter a valid phone number"),
poaAddress: z.string().optional(),
poaEmail: z.string().optional(),
poaEmail: z
.string()
.optional()
.refine(
(v) => !v || z.string().email().safeParse(v).success,
"Invalid email address",
),
poaLocation: z.string().optional(),
});
export type FormData = z.infer<typeof onboardingSchema>;
/** fileKey of the delegation letter uploaded on the Power of Attorney step. */
export const POA_DELEGATION_FILE_KEY = "poa_delegation_letter";
export const POA_FIELDS = [
"poaName",
"poaPhone",
"poaEmail",
"poaLocation",
"poaAddress",
] as const satisfies readonly (keyof FormData)[];
/** True once the customer has entered any Power of Attorney detail. */
export const hasPoaDetails = (d: Partial<FormData>) =>
POA_FIELDS.some((f) => d[f]?.trim());
/**
* A freight forwarder acts on other companies' behalf, so its PoA is mandatory
* rather than optional. Everyone else keeps the optional PoA — but once they
* start filling it in, the identifying fields have to be complete (the
* delegation-letter upload is enforced alongside this, in CompanyProfileForm,
* since files live outside the form state).
*/
export function buildOnboardingSchema(requirePoa: boolean) {
if (!requirePoa) return onboardingSchema;
return onboardingSchema.superRefine((d, ctx) => {
const required: [keyof FormData, string][] = [
["poaName", "PoA name is required for freight forwarders"],
["poaEmail", "PoA email is required for freight forwarders"],
["poaPhone", "PoA phone is required for freight forwarders"],
];
for (const [path, message] of required) {
if (!d[path]?.trim()) {
ctx.addIssue({ code: z.ZodIssueCode.custom, path: [path], message });
}
}
});
}
export const stepFields: Record<CompanyStep, (keyof FormData)[]> = {
company: [
"companyName",
@@ -102,7 +146,7 @@ export const stepFields: Record<CompanyStep, (keyof FormData)[]> = {
"contactPersonEmail",
"contactPersonPhone",
],
poa: [],
poa: [...POA_FIELDS],
documents: [],
additional: [],
};

View File

@@ -144,6 +144,15 @@ export interface OnboardingLicenseProfile {
uploaded: boolean;
}
/** Power of Attorney state — mandatory for freight forwarders, optional otherwise. */
export interface OnboardingPoaState {
required: boolean;
provided: boolean;
delegationLetterUploaded: boolean;
missingFields: { key: string; label: string }[];
complete: boolean;
}
/**
* Server-driven onboarding requirements. The portal renders this verbatim: the
* backend decides which documents apply (by nationality) and what is still
@@ -158,6 +167,7 @@ export interface OnboardingRequirements {
};
documents: OnboardingDocumentField[];
licenseProfiles: OnboardingLicenseProfile[];
poa: OnboardingPoaState;
progress: { completed: number; total: number };
isComplete: boolean;
onboardingCompleted: boolean;