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

@@ -54,6 +54,23 @@ const LICENSE_CODE = "business_license";
/** Code for a license file staged in an open change request (not yet live). */
const LICENSE_PENDING_CODE = "business_license_pending";
/** Mirrors the field seeded in seed/file-upload-settings.seeder.ts. */
const POA_DELEGATION_FILE_KEY = "poa_delegation_letter";
/** company.attributes keys that together mean "a PoA was entered". */
const POA_ATTRIBUTES = [
"poaName",
"poaPhone",
"poaEmail",
"poaLocation",
"poaAddress",
] as const;
/** Mandatory once the company operates as a freight forwarder. */
const REQUIRED_POA_FIELDS: { key: string; label: string }[] = [
{ key: "poaName", label: "PoA name" },
{ key: "poaEmail", label: "PoA email" },
{ key: "poaPhone", label: "PoA phone" },
];
export interface UserIdentity {
userId: string;
firstName: string;
@@ -1240,6 +1257,29 @@ export class CompaniesService {
);
const missingLicenses = licenseProfiles.filter((p) => !p.uploaded);
// 4. Power of Attorney. Optional in general, but a freight forwarder acts on
// other companies' behalf so its PoA is mandatory. Either way, a PoA that
// has been entered must be evidenced by the delegation letter.
const poaRequired = (company.companyProfiles ?? []).some(
(p) => p.type === ProfileType.freightForwarder,
);
const poaProvided = POA_ATTRIBUTES.some((k) =>
(company.attributes?.[k] as string | undefined)?.trim(),
);
const missingPoaFields = poaRequired
? REQUIRED_POA_FIELDS.filter(
(f) => !(company.attributes?.[f.key] as string | undefined)?.trim(),
)
: [];
// Only gate on the letter once the document set actually carries the field.
const delegationField = (setting?.fields ?? []).find(
(f) => f.fileKey === POA_DELEGATION_FILE_KEY,
);
const missingDelegation =
Boolean(delegationField) &&
(poaRequired || poaProvided) &&
!uploadedCodes.has(POA_DELEGATION_FILE_KEY);
const outstanding = [
...missingInfo.map((f) => `Add your ${f.label.toLowerCase()}`),
...missingDocs.map((d) => `Upload your ${d.fileLabel}`),
@@ -1247,18 +1287,31 @@ export class CompaniesService {
(p) =>
`Upload a business license for your ${p.type.replace(/_/g, " ")} profile`,
),
...missingPoaFields.map((f) => `Add your ${f.label.toLowerCase()}`),
...(missingDelegation
? ["Upload the delegation letter for your Power of Attorney"]
: []),
];
// Progress spans every required item the user has to satisfy: company-info
// fields, required documents and one license per operational profile.
// fields, required documents, one license per operational profile, and the
// PoA details/letter whenever those are mandatory.
const requiredDocCount = documents.filter((d) => d.isRequired).length;
const poaItemCount =
(poaRequired ? REQUIRED_POA_FIELDS.length : 0) +
(delegationField && (poaRequired || poaProvided) ? 1 : 0);
const total =
this.REQUIRED_COMPANY_INFO.length +
requiredDocCount +
licenseProfiles.length;
licenseProfiles.length +
poaItemCount;
const completed =
total -
(missingInfo.length + missingDocs.length + missingLicenses.length);
(missingInfo.length +
missingDocs.length +
missingLicenses.length +
missingPoaFields.length +
(missingDelegation ? 1 : 0));
return new OnboardingRequirementsResponseDto({
documentSettingCode,
@@ -1266,6 +1319,13 @@ export class CompaniesService {
companyInfo: { complete: missingInfo.length === 0, missingFields: missingInfo },
documents,
licenseProfiles,
poa: {
required: poaRequired,
provided: poaProvided,
delegationLetterUploaded: uploadedCodes.has(POA_DELEGATION_FILE_KEY),
missingFields: missingPoaFields,
complete: missingPoaFields.length === 0 && !missingDelegation,
},
progress: { completed, total },
isComplete: outstanding.length === 0,
onboardingCompleted: profile.onboardingCompleted,

View File

@@ -35,6 +35,19 @@ export interface OnboardingLicenseProfile {
uploaded: boolean;
}
export interface OnboardingPoaState {
/** True when the company operates as a freight forwarder — PoA is mandatory. */
required: boolean;
/** True once any PoA detail has been entered. */
provided: boolean;
/** True when the delegation letter is stored for the company. */
delegationLetterUploaded: boolean;
/** PoA details still missing (only populated when `required`). */
missingFields: OnboardingInfoField[];
/** False while the PoA step still owes details or a delegation letter. */
complete: boolean;
}
export class OnboardingRequirementsResponseDto {
/** Resolved document setting code (by nationality) the docs were drawn from. */
documentSettingCode: string;
@@ -52,6 +65,9 @@ export class OnboardingRequirementsResponseDto {
/** Per-operational-profile business-license requirements. */
licenseProfiles: OnboardingLicenseProfile[];
/** Power of Attorney state, so the wizard needn't re-derive the rule. */
poa: OnboardingPoaState;
/** Overall setup progress across fields + documents + licenses. */
progress: { completed: number; total: number };
@@ -70,6 +86,7 @@ export class OnboardingRequirementsResponseDto {
this.companyInfo = init.companyInfo;
this.documents = init.documents;
this.licenseProfiles = init.licenseProfiles;
this.poa = init.poa;
this.progress = init.progress;
this.isComplete = init.isComplete;
this.onboardingCompleted = init.onboardingCompleted;

View File

@@ -18,6 +18,28 @@ interface OnboardingField {
const DOC_EXTENSIONS = ["pdf", "jpg", "jpeg", "png"];
/** fileKey of the delegation letter attached to the Power of Attorney step. */
export const POA_DELEGATION_FILE_KEY = "poa_delegation_letter";
/**
* Seeded as optional: the delegation letter is only mandatory once a PoA has
* been entered, or when the company operates as a freight forwarder. That rule
* spans form fields as well as files, so it lives in the onboarding gate
* (companies.service.getOnboardingRequirements) rather than in `isRequired`.
*/
const poaDelegationField = (displayOrder: number): OnboardingField => ({
fileKey: POA_DELEGATION_FILE_KEY,
fileLabel: "PoA Delegation Letter",
helpText:
"Signed letter in which the General Manager delegates the representative named above.",
isRequired: false,
isMultiple: false,
maxFiles: 1,
allowedExtensions: DOC_EXTENSIONS,
maxSizeMb: 10,
displayOrder,
});
/** Documents required from an Ethiopian company at onboarding. */
const ETHIOPIAN_ONBOARDING_FIELDS: OnboardingField[] = [
{
@@ -54,6 +76,7 @@ const ETHIOPIAN_ONBOARDING_FIELDS: OnboardingField[] = [
maxSizeMb: 10,
displayOrder: 3,
},
poaDelegationField(4),
];
/** Documents required from a Foreign company at onboarding. */
@@ -102,6 +125,7 @@ const FOREIGN_ONBOARDING_FIELDS: OnboardingField[] = [
maxSizeMb: 10,
displayOrder: 4,
},
poaDelegationField(5),
];
/** Legacy combined set, kept for the older per-company-type codes. */

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;