mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 08:48:11 +00:00
feat: add poa and poa delegation file to onboarding
This commit is contained in:
@@ -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