feat: implement onboarding process with draft company and profile creation

This commit is contained in:
Marshal
2026-06-19 22:50:24 +00:00
parent 19dea313b7
commit 9f5d287139
9 changed files with 423 additions and 110 deletions

View File

@@ -27,6 +27,7 @@ import { AddCompanyProfilesDto } from "./dto/add-company-profiles.dto";
import { CreateCompanyProfileDto } from "./dto/create-company-profile.dto";
import { SetActiveModeDto } from "./dto/set-active-mode.dto";
import { SetOnboardingStepDto } from "./dto/set-onboarding-step.dto";
import { StartOnboardingDto } from "./dto/start-onboarding.dto";
import {
ResponseCompanyDto,
ResponseCompanyProfileDto,
@@ -108,6 +109,30 @@ export class CompaniesController {
return profiles.map((p) => new ResponseCompanyProfileDto(p));
}
@Post("onboarding/start")
@ApiOperation({
summary:
"Begin onboarding: create a draft company + profile + role(s) so later steps can save incrementally",
})
async startOnboarding(
@CurrentUser() user: CurrentIamUser,
@Body() dto: StartOnboardingDto,
): Promise<CompanyInfoResponseDto> {
const nameParts = (user.name?.en ?? "").split(" ");
const { profile, company } = await this.companiesService.startOnboarding(
{
userId: user.id,
firstName: nameParts[0] || "",
lastName: nameParts.slice(-1)[0] || "",
email: user.email ?? "",
phone: user.phoneNumber ?? "",
},
dto.companyType,
dto.roles,
);
return new CompanyInfoResponseDto(profile, company);
}
@Post("company-profile")
@ApiOperation({
summary:

View File

@@ -15,7 +15,7 @@ import { CreateCompanyWithProfileDto } from "./dto/create-company-with-profile.d
import { UpdateProfileDto } from "./dto/update-profile.dto";
import { ProfileResponseDto } from "./dto/profile-response.dto";
import { DashboardSummaryResponseDto } from "./dto/dashboard-summary-response.dto";
import { Company } from "./entities/company.entity";
import { Company, CompanyStatus, CompanyType } from "./entities/company.entity";
import { ExternalProfile } from "./entities/external-profile.entity";
import {
CompanyProfile,
@@ -137,6 +137,112 @@ export class CompaniesService {
return { company, profile };
}
/**
* Begin onboarding: create a DRAFT company + the user's external profile + the
* chosen operational role(s) up front, so every subsequent wizard step can
* save incrementally (PATCH /profile, /onboarding-step) against existing rows.
*
* Idempotent: if the user already has a profile, returns it unchanged (only
* adding any newly-chosen roles). The draft company carries a placeholder TIN
* (the real one is filled on the Company Information step) and stays
* status=pending / onboardingCompleted=false until the wizard finishes.
*/
async startOnboarding(
identity: UserIdentity,
companyType: CompanyType,
roles: ProfileType[],
): Promise<{ profile: ExternalProfile; company: Company }> {
// Already started — reuse the existing draft, just ensure roles exist.
const existing = await this.profilesRepo.findByUserId(identity.userId);
if (existing) {
const companyId = existing.company?.id ?? existing.companyId;
await this.ensureCompanyProfiles(companyId, companyType, roles);
return this.getCompanyInfoByUserId(identity.userId);
}
// A profile may exist for the same email under a different IAM id — block
// duplicates as the final create does.
const byEmail = await this.profilesRepo.findByEmail(identity.email);
if (byEmail) {
throw new ConflictException(
`Profile with email ${identity.email} already exists`,
);
}
const allowedTypes = this.getProfileTypeForCompanyType(companyType);
const chosenTypes = roles.filter((t) => allowedTypes.includes(t));
const activeProfileType =
chosenTypes.find((t) => t === ProfileType.importer) ??
chosenTypes[0] ??
allowedTypes[0] ??
null;
const company = await this.companiesRepo.create({
name: identity.firstName
? `${identity.firstName}'s company`
: "New company",
type: companyType,
tin: await this.generateDraftTin(),
country: "Ethiopia",
status: CompanyStatus.Pending,
});
await this.profilesRepo.create({
userId: identity.userId,
companyId: company.id,
firstName: identity.firstName,
lastName: identity.lastName,
email: identity.email,
phone: identity.phone,
isPrimaryContact: true,
activeProfileType,
onboardingStep: "company",
onboardingCompleted: false,
});
await this.ensureCompanyProfiles(company.id, companyType, chosenTypes);
return this.getCompanyInfoByUserId(identity.userId);
}
/** Create any of the requested operational profiles that don't exist yet. */
private async ensureCompanyProfiles(
companyId: string,
companyType: CompanyType,
roles: ProfileType[],
): Promise<void> {
const allowedTypes = this.getProfileTypeForCompanyType(companyType);
for (const type of roles) {
if (!allowedTypes.includes(type)) continue;
const existing = await this.companyProfilesRepo.findByType(
companyId,
type,
);
if (existing) continue;
const reference = await this.companyProfilesRepo.generateReference(type);
await this.companyProfilesRepo.create({
companyId,
type,
reference,
status: ProfileStatus.Active,
});
}
}
/**
* A unique 10-char placeholder TIN for a draft company (the column is
* NOT NULL + unique). Overwritten with the real TIN on the company step.
*/
private async generateDraftTin(): Promise<string> {
for (let i = 0; i < 10; i++) {
const candidate =
"D" + Math.floor(Math.random() * 1_000_000_000).toString().padStart(9, "0");
if (!(await this.companiesRepo.existsByTin(candidate))) return candidate;
}
// Extremely unlikely; fall back to a timestamp-derived value.
return ("D" + Date.now().toString()).slice(0, 10);
}
async findAllCompanies(): Promise<Company[]> {
return this.companiesRepo.findAll({ order: { name: "ASC" } });
}
@@ -356,7 +462,17 @@ export class CompaniesService {
companyUpdates.country = dto.companyLocation;
if (dto.companyAddress !== undefined)
companyUpdates.address = dto.companyAddress;
if (dto.tin !== undefined) companyUpdates.tin = dto.tin;
if (dto.tin !== undefined && dto.tin !== company.tin) {
// Reject a TIN already taken by a different company (the user's own draft
// placeholder is fine to overwrite).
const owner = await this.companiesRepo.findByTin(dto.tin);
if (owner && owner.id !== company.id) {
throw new ConflictException(
`Company with TIN ${dto.tin} already exists`,
);
}
companyUpdates.tin = dto.tin;
}
if (dto.vatNumber !== undefined) companyUpdates.vatNumber = dto.vatNumber;
if (dto.fanNumber !== undefined) {
companyUpdates.fanNumber = dto.fanNumber;
@@ -619,9 +735,23 @@ export class CompaniesService {
const profile = await this.profilesRepo.findByUserId(userId);
if (!profile)
throw new NotFoundException(`Profile for user ${userId} not found`);
const companyId = profile.company?.id ?? profile.companyId;
const company = await this.findCompanyById(companyId);
// Guard against finishing on a still-draft company (TIN never filled in).
if (!company.tin || company.tin.startsWith("D")) {
throw new BadRequestException(
"Company information is incomplete — please fill in your company details before finishing.",
);
}
await this.profilesRepo.update(profile.id, {
onboardingCompleted: true,
onboardingStep: 'done',
onboardingStep: "done",
});
await this.companiesRepo.update(companyId, {
status: CompanyStatus.Active,
});
return this.getCompanyInfoByUserId(userId);
}

View File

@@ -0,0 +1,13 @@
import { ArrayMinSize, IsArray, IsEnum } from "class-validator";
import { CompanyType } from "../entities/company.entity";
import { ProfileType } from "../entities/company-profile.entity";
export class StartOnboardingDto {
@IsEnum(CompanyType)
companyType!: CompanyType;
@IsArray()
@ArrayMinSize(1)
@IsEnum(ProfileType, { each: true })
roles!: ProfileType[];
}

View File

@@ -1,15 +1,16 @@
import { Button, Group, Modal, ScrollArea, Stack, Text } from "@mantine/core";
import { Modal, ScrollArea, Stack, Text } from "@mantine/core";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { CheckCircle2 } from "lucide-react";
import { useCallback, useState } from "react";
import useAuth from "@/hooks/useAuth";
import { api } from "@/services/api";
import type {
CompanyProfileInput,
CreateCompanyPayload,
ProfileTypeValue,
} from "@/services/companies.service";
import { companiesService } from "@/services/companies.service";
import type { UpdateProfilePayload } from "@/types/profile";
import { extractApiError } from "@/utils/result";
import CompanyProfileForm from "@/pages/accounts/CompanyProfileForm";
import ForwarderForm from "@/pages/accounts/ForwarderForm";
import { FREIGHT_FORWARDER } from "@/pages/settings/companyRoles";
@@ -44,10 +45,11 @@ function documentSettingCode(companyType: string): string {
}
/**
* Blocking, non-dismissable first-run onboarding wizard. Step 1 picks the
* operational role(s); the remaining steps reuse the existing company/forwarder
* forms. On completion the company is created with its company_profiles and the
* active mode is set server-side, then onboarding is marked complete.
* First-run onboarding wizard with a "draft-first" flow: picking the role(s)
* immediately creates a draft company + profile on the backend, so every
* subsequent step saves its data incrementally (PATCH /profile, /onboarding-step)
* against existing rows. The final step uploads documents and marks onboarding
* complete. Dismissable — the gate keeps it reachable until finished.
*/
export default function OnboardingWizardDialog({
opened,
@@ -56,53 +58,59 @@ export default function OnboardingWizardDialog({
const queryClient = useQueryClient();
const { user, company, onboardingStep } = useAuth();
// A company already exists but onboarding wasn't marked complete (e.g. the
// browser closed after create but before finishing). Don't re-create it —
// just let the user finish.
const companyAlreadyCreated = Boolean(company?.company?.id);
const existingProfiles = company?.company?.companyProfiles ?? [];
const companyAlreadyStarted = Boolean(company?.company?.id);
// Resume position from the backend-persisted step. A form step means the user
// had already passed role selection. Cross-session we still start at role
// selection (the roles + field values aren't persisted), but within a session
// the dialog stays mounted so dismiss/reopen continues exactly where it was.
// Resume position from the backend-persisted step.
const resumeFormStep: FormStep = FORM_STEPS.includes(onboardingStep as FormStep)
? (onboardingStep as FormStep)
: "company";
// "role" → pick roles; otherwise the company/forwarder form drives its own
// internal steps.
const [phase, setPhase] = useState<"role" | "form">("role");
const [roles, setRoles] = useState<string[]>([]);
// If a draft already exists, resume straight into the form with its roles
// pre-selected; otherwise start at role selection.
const [phase, setPhase] = useState<"role" | "form">(
companyAlreadyStarted ? "form" : "role",
);
const [roles, setRoles] = useState<string[]>(
existingProfiles.map((p) => p.type),
);
const [documentFiles, setDocumentFiles] = useState<
Record<string, File | File[] | null>
>({});
const [startError, setStartError] = useState<string | null>(null);
const createCompanyMutation = useMutation({
mutationFn: (payload: CreateCompanyPayload) =>
api.companies.create.call(payload),
onSuccess: async (data) => {
const refreshInfo = useCallback(
() =>
queryClient.invalidateQueries({
queryKey: api.companies.getInfo.queryKey(),
}),
[queryClient],
);
// Begin onboarding: create the draft company + profile + role(s).
const startMutation = useMutation({
mutationFn: (vars: { companyType: string; roles: ProfileTypeValue[] }) =>
api.companies.startOnboarding.call(vars),
onSuccess: async () => {
await refreshInfo();
setPhase("form");
},
onError: (err) => setStartError(extractApiError(err).message),
});
// Finalize: upload any documents, then mark onboarding complete.
const finishMutation = useMutation({
mutationFn: async () => {
const companyId = company?.company?.id;
const hasFiles = Object.values(documentFiles).some(
(f) => f !== null && (Array.isArray(f) ? f.length > 0 : true),
);
if (hasFiles) {
await companiesService.uploadDocuments(data.company.id, documentFiles);
if (companyId && hasFiles) {
await companiesService.uploadDocuments(companyId, documentFiles);
}
// Mark onboarding complete, then refresh the company info so the gate
// releases and the header reflects the new profile(s).
await api.companies.completeOnboarding.call();
await queryClient.invalidateQueries({
queryKey: api.companies.getInfo.queryKey(),
});
},
});
const finishMutation = useMutation({
mutationFn: () => api.companies.completeOnboarding.call(),
onSuccess: async () => {
await queryClient.invalidateQueries({
queryKey: api.companies.getInfo.queryKey(),
});
return api.companies.completeOnboarding.call();
},
onSuccess: refreshInfo,
});
// Persist the resume step to the backend (best-effort, fire-and-forget).
@@ -111,27 +119,39 @@ export default function OnboardingWizardDialog({
}, []);
const handleRolesContinue = useCallback(() => {
setPhase("form");
persistStep("company");
}, [persistStep]);
setStartError(null);
startMutation.mutate({
companyType: companyTypeForRoles(roles),
roles: roles as ProfileTypeValue[],
});
}, [roles, startMutation]);
const handleBackToRoles = useCallback(() => {
setPhase("role");
persistStep("role");
}, [persistStep]);
const handleSubmit = useCallback(
(payload: CreateCompanyPayload) => {
const companyProfiles: CompanyProfileInput[] = roles.map((type) => ({
type: type as CompanyProfileInput["type"],
}));
createCompanyMutation.mutate({
...payload,
companyType: companyTypeForRoles(roles),
companyProfiles,
});
// Save the current step's fields to the draft (PATCH /profile). Returns false
// to keep the form on the current step when the save fails.
const saveStep = useCallback(
async (data: Partial<UpdateProfilePayload>): Promise<boolean> => {
try {
await api.companies.updateProfile.call(data as UpdateProfilePayload);
return true;
} catch {
return false;
}
},
[roles, createCompanyMutation],
[],
);
// Final confirm step → finalize onboarding (no company create; it already
// exists as a draft that's been filled in step-by-step).
const handleSubmit = useCallback(
(_payload: CreateCompanyPayload) => {
finishMutation.mutate();
},
[finishMutation],
);
if (!user) return null;
@@ -161,37 +181,26 @@ export default function OnboardingWizardDialog({
Complete your onboarding
</Text>
<Text size="sm" c="edr-muted">
{companyAlreadyCreated
? "You're almost there — finish to start using the portal."
: phase === "role"
? "Tell us what your company does to get started."
: "Set up your company profile to finish."}
{phase === "role"
? "Tell us what your company does to get started."
: "Set up your company profile to finish."}
</Text>
</Stack>
}
>
{companyAlreadyCreated ? (
<Stack gap="lg" align="center" py="md">
<CheckCircle2 size={48} className="text-[var(--mantine-color-edr-green-6)]" />
<Text ta="center" c="edr-muted" size="sm" maw={420}>
Your company profile is set up. Click finish to complete onboarding
and unlock the rest of the portal.
</Text>
<Group justify="center">
<Button
color="edr-green"
size="md"
loading={finishMutation.isPending}
onClick={() => finishMutation.mutate()}
>
Finish onboarding
</Button>
</Group>
</Stack>
) : phase === "role" ? (
{phase === "role" ? (
<Stack gap="lg">
<OnboardingRoleSelect value={roles} onChange={setRoles} />
<RoleContinueBar disabled={!rolesValid} onClick={handleRolesContinue} />
{startError && (
<Text size="sm" c="red">
{startError}
</Text>
)}
<RoleContinueBar
disabled={!rolesValid}
loading={startMutation.isPending}
onClick={handleRolesContinue}
/>
</Stack>
) : isForwarder ? (
<ForwarderForm
@@ -200,10 +209,11 @@ export default function OnboardingWizardDialog({
onDocumentFilesChange={setDocumentFiles}
user={user}
onSubmit={handleSubmit}
isPending={createCompanyMutation.isPending}
isPending={finishMutation.isPending}
onBack={handleBackToRoles}
initialStep={resumeFormStep}
onStepChange={persistStep}
onSaveStep={saveStep}
/>
) : (
<CompanyProfileForm
@@ -212,10 +222,11 @@ export default function OnboardingWizardDialog({
onDocumentFilesChange={setDocumentFiles}
user={user}
onSubmit={handleSubmit}
isPending={createCompanyMutation.isPending}
isPending={finishMutation.isPending}
onBack={handleBackToRoles}
initialStep={resumeFormStep}
onStepChange={persistStep}
onSaveStep={saveStep}
/>
)}
</Modal>
@@ -224,19 +235,21 @@ export default function OnboardingWizardDialog({
function RoleContinueBar({
disabled,
loading,
onClick,
}: {
disabled: boolean;
loading?: boolean;
onClick: () => void;
}) {
return (
<button
type="button"
disabled={disabled}
disabled={disabled || loading}
onClick={onClick}
className="ml-auto rounded-lg bg-[var(--mantine-color-edr-green-6)] px-5 py-2.5 text-sm font-semibold text-white transition-opacity disabled:cursor-not-allowed disabled:opacity-50"
className="ml-auto rounded-lg bg-[var(--mantine-color-edr-green-6)] px-5 py-2.5 text-sm font-semibold text-white transition-opacity hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-50"
>
Continue
{loading ? "Setting up…" : "Continue"}
</button>
);
}

View File

@@ -86,6 +86,7 @@ export const URL_CONSTANTS = {
COMPANY_PROFILES: "/api/companies/company-profiles",
COMPANY_PROFILE: "/api/companies/company-profile",
ACTIVE_MODE: "/api/companies/active-mode",
ONBOARDING_START: "/api/companies/onboarding/start",
ONBOARDING_STEP: "/api/companies/onboarding-step",
ONBOARDING_COMPLETE: "/api/companies/onboarding/complete",
DASHBOARD: "/api/companies/dashboard",

View File

@@ -28,6 +28,7 @@ import { z } from "zod";
import type { AuthUser } from "@/types/auth";
import type { CreateCompanyPayload } from "@/services/companies.service";
import type { UpdateProfilePayload } from "@/types/profile";
import PhoneInput from "@/components/auth/PhoneInput";
import { SmartFileInput } from "@edr/ui-common";
import { api } from "@/services/api";
@@ -118,6 +119,44 @@ function buildPayload(data: FormData, _user: AuthUser): CreateCompanyPayload {
};
}
/** Map one wizard step's form values to the profile-update payload it saves. */
function stepPayload(step: CompanyStep, d: FormData): Partial<UpdateProfilePayload> {
switch (step) {
case "company":
return {
companyName: d.companyName,
companyEmail: d.companyEmail,
companyPhone: `${d.companyPhoneCountryCode}${d.companyPhone}`,
companyLocation: d.companyLocation,
companyAddress: d.companyAddress,
tin: d.tinNumber,
vatNumber: d.vatNumber,
fanNumber: d.fanNumber,
};
case "personnel":
return {
contactPersonName: d.contactPersonName,
contactPersonPhone: `${d.contactPersonPhoneCountryCode}${d.contactPersonPhone}`,
generalManagerName: d.generalManagerName,
generalManagerEmail: d.generalManagerEmail,
generalManagerPhone: `${d.generalManagerPhoneCountryCode}${d.generalManagerPhone}`,
};
case "poa":
return {
poaName: d.poaName || undefined,
poaPhone:
d.poaPhone && d.poaPhoneCountryCode
? `${d.poaPhoneCountryCode}${d.poaPhone}`
: undefined,
poaEmail: d.poaEmail || undefined,
poaLocation: d.poaLocation || undefined,
poaAddress: d.poaAddress || undefined,
};
default:
return {};
}
}
export default function CompanyProfileForm({
documentSettingCode,
documentFiles: controlledFiles,
@@ -128,6 +167,7 @@ export default function CompanyProfileForm({
onBack,
initialStep,
onStepChange,
onSaveStep,
}: {
documentSettingCode: string;
documentFiles?: Record<string, File | File[] | null>;
@@ -140,8 +180,11 @@ export default function CompanyProfileForm({
initialStep?: CompanyStep;
/** Reports the active step so the parent can persist resume progress. */
onStepChange?: (step: CompanyStep) => void;
/** Persist the current step's data before advancing (returns false to block). */
onSaveStep?: (data: Partial<UpdateProfilePayload>) => Promise<boolean>;
}) {
const [step, setStep] = useState<CompanyStep>(initialStep ?? "company");
const [saving, setSaving] = useState(false);
// Report each step change up so the wizard can persist it for resume.
useEffect(() => {
@@ -199,22 +242,34 @@ export default function CompanyProfileForm({
const hasDocuments = Boolean(uploadSetting?.fields?.length);
const totalSteps = 5;
/** Validate + persist the current step, returning whether we may advance. */
const saveCurrentStep = async (): Promise<boolean> => {
const isValid = await trigger(stepFields[step]);
if (!isValid) return false;
if (!onSaveStep) return true;
setSaving(true);
try {
return await onSaveStep(stepPayload(step, watch()));
} finally {
setSaving(false);
}
};
const nextStep = async () => {
if (step === "poa") {
setStep("documents");
if (step === "confirm") {
handleSubmit((data) => onSubmit(buildPayload(data, user)))();
return;
}
if (step === "documents") {
setStep("confirm");
return;
}
if (step === "confirm") {
handleSubmit((data) => onSubmit(buildPayload(data, user)))();
return;
}
const isValid = await trigger(stepFields[step]);
if (!isValid) return;
setStep(step === "company" ? "personnel" : "poa");
// company / personnel / poa: validate + save before advancing.
const ok = await saveCurrentStep();
if (!ok) return;
setStep(
step === "company" ? "personnel" : step === "personnel" ? "poa" : "documents",
);
};
const prevStep = () => {
@@ -593,11 +648,15 @@ export default function CompanyProfileForm({
}
disabled={
isPending ||
saving ||
(step === "documents" && !hasDocuments && loadingDocuments)
}
loading={isPending}
loading={isPending || saving}
rightSection={
!isPending && step !== "confirm" && step !== "documents" ? (
!isPending &&
!saving &&
step !== "confirm" &&
step !== "documents" ? (
<ArrowRight size={16} />
) : undefined
}
@@ -606,7 +665,7 @@ export default function CompanyProfileForm({
? "Continue"
: step === "confirm"
? "Submit Registration"
: "Next Step"}
: "Save & Continue"}
</Button>
</Group>
</Stack>

View File

@@ -17,6 +17,7 @@ import { z } from "zod";
import type { AuthUser } from "@/types/auth";
import type { CreateCompanyPayload } from "@/services/companies.service";
import type { UpdateProfilePayload } from "@/types/profile";
import PhoneInput from "@/components/auth/PhoneInput";
import { SmartFileInput } from "@edr/ui-common";
import { api } from "@/services/api";
@@ -83,6 +84,44 @@ function buildPayload(data: FormData, _user: AuthUser): CreateCompanyPayload {
};
}
/** Map one wizard step's form values to the profile-update payload it saves. */
function stepPayload(step: ForwarderStep, d: FormData): Partial<UpdateProfilePayload> {
switch (step) {
case "company":
return {
companyName: d.companyName,
companyEmail: d.companyEmail,
companyPhone: `${d.companyPhoneCountryCode}${d.companyPhone}`,
companyLocation: d.companyLocation,
companyAddress: d.companyAddress,
tin: d.tinNumber,
vatNumber: d.vatNumber,
fanNumber: d.fanNumber,
};
case "personnel":
return {
contactPersonName: d.contactPersonName,
contactPersonPhone: `${d.contactPersonPhoneCountryCode}${d.contactPersonPhone}`,
generalManagerName: d.generalManagerName,
generalManagerEmail: d.generalManagerEmail,
generalManagerPhone: `${d.generalManagerPhoneCountryCode}${d.generalManagerPhone}`,
};
case "poa":
return {
poaName: d.poaName || undefined,
poaPhone:
d.poaPhone && d.poaPhoneCountryCode
? `${d.poaPhoneCountryCode}${d.poaPhone}`
: undefined,
poaEmail: d.poaEmail || undefined,
poaLocation: d.poaLocation || undefined,
poaAddress: d.poaAddress || undefined,
};
default:
return {};
}
}
export default function ForwarderForm({
documentSettingCode,
documentFiles: controlledFiles,
@@ -93,6 +132,7 @@ export default function ForwarderForm({
onBack,
initialStep,
onStepChange,
onSaveStep,
}: {
documentSettingCode: string;
documentFiles?: Record<string, File | File[] | null>;
@@ -105,8 +145,11 @@ export default function ForwarderForm({
initialStep?: ForwarderStep;
/** Reports the active step so the parent can persist resume progress. */
onStepChange?: (step: ForwarderStep) => void;
/** Persist the current step's data before advancing (returns false to block). */
onSaveStep?: (data: Partial<UpdateProfilePayload>) => Promise<boolean>;
}) {
const [step, setStep] = useState<ForwarderStep>(initialStep ?? "company");
const [saving, setSaving] = useState(false);
// Report each step change up so the wizard can persist it for resume.
useEffect(() => {
@@ -136,13 +179,25 @@ export default function ForwarderForm({
const hasDocuments = Boolean(uploadSetting?.fields?.length);
const totalSteps = 5;
const nextStep = async () => {
if (step === "poa") { setStep("documents"); return; }
if (step === "documents") { setStep("confirm"); return; }
if (step === "confirm") { handleSubmit((data) => onSubmit(buildPayload(data, user)))(); return; }
/** Validate + persist the current step, returning whether we may advance. */
const saveCurrentStep = async (): Promise<boolean> => {
const isValid = await trigger(stepFields[step]);
if (!isValid) return;
setStep(step === "company" ? "personnel" : "poa");
if (!isValid) return false;
if (!onSaveStep) return true;
setSaving(true);
try {
return await onSaveStep(stepPayload(step, watch()));
} finally {
setSaving(false);
}
};
const nextStep = async () => {
if (step === "confirm") { handleSubmit((data) => onSubmit(buildPayload(data, user)))(); return; }
if (step === "documents") { setStep("confirm"); return; }
const ok = await saveCurrentStep();
if (!ok) return;
setStep(step === "company" ? "personnel" : step === "personnel" ? "poa" : "documents");
};
const skipDocuments = () => setStep("confirm");
@@ -423,18 +478,18 @@ export default function ForwarderForm({
</Button>
<Group gap="sm">
{step === "documents" && (
<Button variant="default" onClick={skipDocuments} disabled={isPending}>
<Button variant="default" onClick={skipDocuments} disabled={isPending || saving}>
Skip for now
</Button>
)}
<Button
color="edr-green"
onClick={step === "confirm" ? handleSubmit((data) => onSubmit(buildPayload(data, user))) : nextStep}
disabled={isPending || (step === "documents" && !hasDocuments && loadingDocuments)}
loading={isPending}
rightSection={!isPending && step !== "confirm" && step !== "documents" ? <ArrowRight size={16} /> : undefined}
disabled={isPending || saving || (step === "documents" && !hasDocuments && loadingDocuments)}
loading={isPending || saving}
rightSection={!isPending && !saving && step !== "confirm" && step !== "documents" ? <ArrowRight size={16} /> : undefined}
>
{step === "documents" ? "Continue" : step === "confirm" ? "Submit Registration" : "Next Step"}
{step === "documents" ? "Continue" : step === "confirm" ? "Submit Registration" : "Save & Continue"}
</Button>
</Group>
</Group>

View File

@@ -140,6 +140,11 @@ export const api = {
CompanyProfileResponse
>("companies", "createCompanyProfile", companiesService.createCompanyProfile),
startOnboarding: endpoint<
{ companyType: string; roles: ProfileTypeValue[] },
CompanyInfoResponse
>("companies", "startOnboarding", companiesService.startOnboarding),
setActiveMode: endpoint<{ type: ProfileTypeValue }, CompanyInfoResponse>(
"companies",
"setActiveMode",

View File

@@ -177,6 +177,18 @@ export const companiesService = {
return unwrap(response.data);
},
/** Begin onboarding — create the draft company + profile + role(s) up front. */
startOnboarding: async (payload: {
companyType: string;
roles: ProfileTypeValue[];
}): Promise<CompanyInfoResponse> => {
const response = await client.post<ApiResponse<CompanyInfoResponse>>(
URL_CONSTANTS.COMPANIES_API.ONBOARDING_START,
payload,
);
return unwrap(response.data);
},
/** Switch the active operational mode (target profile must already exist). */
setActiveMode: async (payload: {
type: ProfileTypeValue;