mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 00:52:50 +00:00
feat: implement onboarding process with draft company and profile creation
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user