feat: enhance onboarding forms with error handling and step persistence

This commit is contained in:
Marshal
2026-06-19 23:04:39 +00:00
parent 9f5d287139
commit ce8189d5fe
4 changed files with 132 additions and 36 deletions

View File

@@ -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;

View File

@@ -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<UpdateProfilePayload>): Promise<boolean> => {
async (
data: Partial<UpdateProfilePayload>,
): 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}
/>

View File

@@ -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<UpdateProfilePayload>) => Promise<boolean>;
/** Persist the current step's data before advancing; returns an error to show. */
onSaveStep?: (
data: Partial<UpdateProfilePayload>,
) => Promise<{ ok: true } | { ok: false; error: string }>;
}) {
const [step, setStep] = useState<CompanyStep>(initialStep ?? "company");
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(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<string, File | File[] | null>
>({});
@@ -244,12 +267,18 @@ export default function CompanyProfileForm({
/** Validate + persist the current step, returning whether we may advance. */
const saveCurrentStep = async (): Promise<boolean> => {
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: <Building2 size={18} /> },
{ key: "personnel", icon: <User size={18} /> },
@@ -627,18 +661,29 @@ export default function CompanyProfileForm({
</Box>
)}
<Group justify="space-between" pt="xs">
<Button
variant="default"
onClick={prevStep}
leftSection={<ArrowLeft size={16} />}
{saveError && (
<Alert
color="red"
variant="light"
icon={<AlertCircle size={18} />}
title="Couldn't save this step"
>
{step === "company"
? "Change Type"
: step === "confirm"
? "Back to Documents"
: "Back"}
</Button>
{saveError}
</Alert>
)}
<Group justify="space-between" pt="xs">
{showBack ? (
<Button
variant="default"
onClick={prevStep}
leftSection={<ArrowLeft size={16} />}
>
{step === "confirm" ? "Back to Documents" : "Back"}
</Button>
) : (
<span />
)}
<Button
color="edr-green"
onClick={

View File

@@ -1,7 +1,8 @@
import { Box, Button, Divider, Group, Loader, SimpleGrid, Stack, Text, TextInput, ThemeIcon } from "@mantine/core";
import { Alert, Box, Button, Divider, Group, Loader, SimpleGrid, Stack, Text, TextInput, ThemeIcon } from "@mantine/core";
import { zodResolver } from "@hookform/resolvers/zod";
import { useQuery } from "@tanstack/react-query";
import {
AlertCircle,
ArrowLeft,
ArrowRight,
Building2,
@@ -11,7 +12,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";
@@ -131,6 +132,8 @@ export default function ForwarderForm({
isPending,
onBack,
initialStep,
resyncOpen,
hideFirstStepBack,
onStepChange,
onSaveStep,
}: {
@@ -143,19 +146,37 @@ export default function ForwarderForm({
onBack: () => void;
/** Step to resume at (defaults to "company"). */
initialStep?: ForwarderStep;
/** 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: ForwarderStep) => void;
/** Persist the current step's data before advancing (returns false to block). */
onSaveStep?: (data: Partial<UpdateProfilePayload>) => Promise<boolean>;
/** Persist the current step's data before advancing; returns an error to show. */
onSaveStep?: (
data: Partial<UpdateProfilePayload>,
) => Promise<{ ok: true } | { ok: false; error: string }>;
}) {
const [step, setStep] = useState<ForwarderStep>(initialStep ?? "company");
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(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 so progress never resets.
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<string, File | File[] | null>>({});
const documentFiles = controlledFiles ?? internalFiles;
const setDocumentFiles = onDocumentFilesChange ?? setInternalFiles;
@@ -181,12 +202,18 @@ export default function ForwarderForm({
/** Validate + persist the current step, returning whether we may advance. */
const saveCurrentStep = async (): Promise<boolean> => {
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);
}
@@ -203,6 +230,7 @@ export default function ForwarderForm({
const skipDocuments = () => setStep("confirm");
const prevStep = () => {
setSaveError(null);
if (step === "company") onBack();
else if (step === "personnel") setStep("company");
else if (step === "poa") setStep("personnel");
@@ -210,6 +238,8 @@ export default function ForwarderForm({
else setStep("documents");
};
const showBack = !(hideFirstStepBack && step === "company");
const STEPS: { key: ForwarderStep; icon: React.ReactNode }[] = [
{ key: "company", icon: <Building2 size={18} /> },
{ key: "personnel", icon: <User size={18} /> },
@@ -472,10 +502,20 @@ export default function ForwarderForm({
</Box>
)}
{saveError && (
<Alert color="red" variant="light" icon={<AlertCircle size={18} />} title="Couldn't save this step">
{saveError}
</Alert>
)}
<Group justify="space-between" pt="xs">
<Button variant="default" onClick={prevStep} leftSection={<ArrowLeft size={16} />}>
{step === "company" ? "Change Type" : step === "confirm" ? "Back to Documents" : "Back"}
</Button>
{showBack ? (
<Button variant="default" onClick={prevStep} leftSection={<ArrowLeft size={16} />}>
{step === "confirm" ? "Back to Documents" : "Back"}
</Button>
) : (
<span />
)}
<Group gap="sm">
{step === "documents" && (
<Button variant="default" onClick={skipDocuments} disabled={isPending || saving}>