mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
feat: add poa and poa delegation file to onboarding
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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: [],
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user