From ce8189d5fe7889c0d5a08c12c57029abe2b22c1e Mon Sep 17 00:00:00 2001 From: Marshal Date: Fri, 19 Jun 2026 23:04:39 +0000 Subject: [PATCH] feat: enhance onboarding forms with error handling and step persistence --- .../modules/companies/companies.service.ts | 2 +- .../onboarding/OnboardingWizardDialog.tsx | 35 ++++++--- .../src/pages/accounts/CompanyProfileForm.tsx | 75 +++++++++++++++---- .../src/pages/accounts/ForwarderForm.tsx | 56 ++++++++++++-- 4 files changed, 132 insertions(+), 36 deletions(-) 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 65a736e1f..dae561470 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.service.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.service.ts @@ -468,7 +468,7 @@ export class CompaniesService { const owner = await this.companiesRepo.findByTin(dto.tin); if (owner && owner.id !== company.id) { throw new ConflictException( - `Company with TIN ${dto.tin} already exists`, + `This TIN (${dto.tin}) is already registered to another company. Please check the number and try again.`, ); } companyUpdates.tin = dto.tin; 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 6cd7f21b1..ddb046004 100644 --- a/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx +++ b/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx @@ -1,6 +1,6 @@ import { Modal, ScrollArea, Stack, Text } from "@mantine/core"; import { useMutation, useQueryClient } from "@tanstack/react-query"; -import { useCallback, useState } from "react"; +import { useCallback, useRef, useState } from "react"; import useAuth from "@/hooks/useAuth"; import { api } from "@/services/api"; @@ -113,8 +113,14 @@ export default function OnboardingWizardDialog({ onSuccess: refreshInfo, }); - // Persist the resume step to the backend (best-effort, fire-and-forget). + // Persist the resume step to the backend, but only ever move FORWARD — going + // Back must never downgrade the furthest step the user reached, so reopening + // always lands on the furthest step. + const furthestIdxRef = useRef(FORM_STEPS.indexOf(resumeFormStep)); const persistStep = useCallback((step: string) => { + const idx = FORM_STEPS.indexOf(step as FormStep); + if (idx < 0 || idx <= furthestIdxRef.current) return; + furthestIdxRef.current = idx; api.companies.setOnboardingStep.call({ step }).catch(() => {}); }, []); @@ -126,20 +132,21 @@ export default function OnboardingWizardDialog({ }); }, [roles, startMutation]); - const handleBackToRoles = useCallback(() => { - setPhase("role"); - persistStep("role"); - }, [persistStep]); + // Note: no "back to role selection" — once the draft is created the role(s) + // are fixed; the form's first-step Back is a no-op so progress never resets. + const handleBackToRoles = useCallback(() => {}, []); - // 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. + // Save the current step's fields to the draft (PATCH /profile). Returns the + // server error message on failure so the form can show it (e.g. duplicate TIN). const saveStep = useCallback( - async (data: Partial): Promise => { + async ( + data: Partial, + ): Promise<{ ok: true } | { ok: false; error: string }> => { try { await api.companies.updateProfile.call(data as UpdateProfilePayload); - return true; - } catch { - return false; + return { ok: true }; + } catch (err) { + return { ok: false, error: extractApiError(err).message }; } }, [], @@ -211,7 +218,9 @@ export default function OnboardingWizardDialog({ onSubmit={handleSubmit} isPending={finishMutation.isPending} onBack={handleBackToRoles} + hideFirstStepBack initialStep={resumeFormStep} + resyncOpen={opened} onStepChange={persistStep} onSaveStep={saveStep} /> @@ -224,7 +233,9 @@ export default function OnboardingWizardDialog({ onSubmit={handleSubmit} isPending={finishMutation.isPending} onBack={handleBackToRoles} + hideFirstStepBack initialStep={resumeFormStep} + resyncOpen={opened} onStepChange={persistStep} onSaveStep={saveStep} /> 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 d51f31490..2ce65b29e 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx @@ -1,4 +1,5 @@ import { + Alert, Box, Button, Divider, @@ -13,6 +14,7 @@ import { import { zodResolver } from "@hookform/resolvers/zod"; import { useQuery } from "@tanstack/react-query"; import { + AlertCircle, ArrowLeft, ArrowRight, Building2, @@ -22,7 +24,7 @@ import { UploadCloud, User, } from "lucide-react"; -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { useForm } from "react-hook-form"; import { z } from "zod"; @@ -166,6 +168,8 @@ export default function CompanyProfileForm({ isPending, onBack, initialStep, + resyncOpen, + hideFirstStepBack, onStepChange, onSaveStep, }: { @@ -178,19 +182,38 @@ export default function CompanyProfileForm({ onBack: () => void; /** Step to resume at (defaults to "company"). */ initialStep?: CompanyStep; + /** When this flips true (dialog reopened), jump back to initialStep (furthest reached). */ + resyncOpen?: boolean; + /** Hide the Back button on the first step (onboarding can't go back to role pick). */ + hideFirstStepBack?: boolean; /** 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; + /** Persist the current step's data before advancing; returns an error to show. */ + onSaveStep?: ( + data: Partial, + ) => Promise<{ ok: true } | { ok: false; error: string }>; }) { const [step, setStep] = useState(initialStep ?? "company"); const [saving, setSaving] = useState(false); + const [saveError, setSaveError] = useState(null); // Report each step change up so the wizard can persist it for resume. useEffect(() => { onStepChange?.(step); // eslint-disable-next-line react-hooks/exhaustive-deps }, [step]); + + // On reopen, jump to the furthest step reached (initialStep) so progress + // never appears to reset. + const wasOpen = useRef(resyncOpen); + useEffect(() => { + if (resyncOpen && !wasOpen.current && initialStep) { + setStep(initialStep); + setSaveError(null); + } + wasOpen.current = resyncOpen; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [resyncOpen]); const [internalFiles, setInternalFiles] = useState< Record >({}); @@ -244,12 +267,18 @@ export default function CompanyProfileForm({ /** Validate + persist the current step, returning whether we may advance. */ const saveCurrentStep = async (): Promise => { + setSaveError(null); const isValid = await trigger(stepFields[step]); if (!isValid) return false; if (!onSaveStep) return true; setSaving(true); try { - return await onSaveStep(stepPayload(step, watch())); + const res = await onSaveStep(stepPayload(step, watch())); + if (!res.ok) { + setSaveError(res.error); + return false; + } + return true; } finally { setSaving(false); } @@ -273,6 +302,7 @@ export default function CompanyProfileForm({ }; const prevStep = () => { + setSaveError(null); if (step === "company") onBack(); else if (step === "personnel") setStep("company"); else if (step === "poa") setStep("personnel"); @@ -280,6 +310,10 @@ export default function CompanyProfileForm({ else setStep("documents"); }; + // Back is hidden on the first step during onboarding (can't return to role + // selection); otherwise always available. + const showBack = !(hideFirstStepBack && step === "company"); + const STEPS: { key: CompanyStep; icon: React.ReactNode }[] = [ { key: "company", icon: }, { key: "personnel", icon: }, @@ -627,18 +661,29 @@ export default function CompanyProfileForm({ )} - - + {saveError} + + )} + + + {showBack ? ( + + ) : ( + + )} + {showBack ? ( + + ) : ( + + )} {step === "documents" && (