From 9f5d28713956adb47bfa24ebbe45c37a1fea662c Mon Sep 17 00:00:00 2001 From: Marshal Date: Fri, 19 Jun 2026 22:50:24 +0000 Subject: [PATCH] feat: implement onboarding process with draft company and profile creation --- .../modules/companies/companies.controller.ts | 25 +++ .../modules/companies/companies.service.ts | 136 ++++++++++++- .../companies/dto/start-onboarding.dto.ts | 13 ++ .../onboarding/OnboardingWizardDialog.tsx | 181 ++++++++++-------- .../portal/src/constants/URLS.ts | 1 + .../src/pages/accounts/CompanyProfileForm.tsx | 83 ++++++-- .../src/pages/accounts/ForwarderForm.tsx | 77 ++++++-- .../portal/src/services/api.ts | 5 + .../portal/src/services/companies.service.ts | 12 ++ 9 files changed, 423 insertions(+), 110 deletions(-) create mode 100644 apps/edr-freight-api/src/modules/companies/dto/start-onboarding.dto.ts diff --git a/apps/edr-freight-api/src/modules/companies/companies.controller.ts b/apps/edr-freight-api/src/modules/companies/companies.controller.ts index d09effef3..21cbfd67b 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.controller.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.controller.ts @@ -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 { + 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: diff --git a/apps/edr-freight-api/src/modules/companies/companies.service.ts b/apps/edr-freight-api/src/modules/companies/companies.service.ts index edeaafe57..65a736e1f 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.service.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.service.ts @@ -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 { + 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 { + 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 { 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); } diff --git a/apps/edr-freight-api/src/modules/companies/dto/start-onboarding.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/start-onboarding.dto.ts new file mode 100644 index 000000000..edbe13145 --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/dto/start-onboarding.dto.ts @@ -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[]; +} diff --git a/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx b/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx index 9b11267cc..6cd7f21b1 100644 --- a/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx +++ b/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx @@ -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([]); + // 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( + existingProfiles.map((p) => p.type), + ); const [documentFiles, setDocumentFiles] = useState< Record >({}); + const [startError, setStartError] = useState(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): Promise => { + 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 - {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."} } > - {companyAlreadyCreated ? ( - - - - Your company profile is set up. Click finish to complete onboarding - and unlock the rest of the portal. - - - - - - ) : phase === "role" ? ( + {phase === "role" ? ( - + {startError && ( + + {startError} + + )} + ) : isForwarder ? ( ) : ( )} @@ -224,19 +235,21 @@ export default function OnboardingWizardDialog({ function RoleContinueBar({ disabled, + loading, onClick, }: { disabled: boolean; + loading?: boolean; onClick: () => void; }) { return ( ); } diff --git a/apps/edr-freight-web/portal/src/constants/URLS.ts b/apps/edr-freight-web/portal/src/constants/URLS.ts index 195dcb692..3854ecdca 100644 --- a/apps/edr-freight-web/portal/src/constants/URLS.ts +++ b/apps/edr-freight-web/portal/src/constants/URLS.ts @@ -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", diff --git a/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx b/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx index 913726008..d51f31490 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx @@ -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 { + 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; @@ -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) => Promise; }) { const [step, setStep] = useState(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 => { + 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" ? ( ) : undefined } @@ -606,7 +665,7 @@ export default function CompanyProfileForm({ ? "Continue" : step === "confirm" ? "Submit Registration" - : "Next Step"} + : "Save & Continue"} diff --git a/apps/edr-freight-web/portal/src/pages/accounts/ForwarderForm.tsx b/apps/edr-freight-web/portal/src/pages/accounts/ForwarderForm.tsx index 6f06d9c48..8efcd6a7f 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/ForwarderForm.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/ForwarderForm.tsx @@ -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 { + 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; @@ -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) => Promise; }) { const [step, setStep] = useState(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 => { 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({ {step === "documents" && ( - )} diff --git a/apps/edr-freight-web/portal/src/services/api.ts b/apps/edr-freight-web/portal/src/services/api.ts index e2c92fde6..8f14b0062 100644 --- a/apps/edr-freight-web/portal/src/services/api.ts +++ b/apps/edr-freight-web/portal/src/services/api.ts @@ -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", diff --git a/apps/edr-freight-web/portal/src/services/companies.service.ts b/apps/edr-freight-web/portal/src/services/companies.service.ts index e71949045..2789a9e3a 100644 --- a/apps/edr-freight-web/portal/src/services/companies.service.ts +++ b/apps/edr-freight-web/portal/src/services/companies.service.ts @@ -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 => { + const response = await client.post>( + 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;