mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 00:52:50 +00:00
feat: enhance onboarding forms with error handling and step persistence
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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={
|
||||
|
||||
@@ -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}>
|
||||
|
||||
Reference in New Issue
Block a user