feat: add company selection to settings onboarding

This commit is contained in:
Nathnael
2026-06-19 09:07:11 +00:00
parent 1ea2f065c6
commit 8e278a3bd6
6 changed files with 271 additions and 98 deletions

View File

@@ -92,6 +92,34 @@ export class CompaniesService {
isPrimaryContact: dto.isPrimaryContact ?? true,
});
// Persist the operational role(s) chosen during onboarding. Types are
// already constrained to the company type on the client; any that don't
// match are skipped defensively rather than failing the whole signup.
if (dto.companyProfiles?.length) {
const allowedTypes = this.getProfileTypeForCompanyType(company.type);
for (const input of dto.companyProfiles) {
if (!allowedTypes.includes(input.type)) continue;
const existing = await this.companyProfilesRepo.findByType(
company.id,
input.type,
);
if (existing) continue;
const reference = await this.companyProfilesRepo.generateReference(
input.type,
);
await this.companyProfilesRepo.create({
companyId: company.id,
type: input.type,
reference,
businessLicense: input.businessLicense ?? null,
status: ProfileStatus.Active,
});
}
company.companyProfiles = await this.companyProfilesRepo.findByCompanyId(
company.id,
);
}
return { company, profile };
}

View File

@@ -1,65 +1,18 @@
import { useMemo, useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Building2, CheckCircle2, Save, XCircle } from "lucide-react";
import {
ArrowDownToLine,
ArrowUpFromLine,
Building2,
Check,
CheckCircle2,
Save,
XCircle,
} from "lucide-react";
import {
Box,
Button,
Card,
Group,
SimpleGrid,
Text,
ThemeIcon,
Title,
UnstyledButton,
} from "@mantine/core";
import { api } from "@/services/api";
import type { ProfileResponse } from "@/types/profile";
interface RoleOption {
type: string;
label: string;
description: string;
icon: React.ReactNode;
}
const CUSTOMER_ROLES: RoleOption[] = [
{
type: "importer",
label: "Importer",
description: "Import goods into Ethiopia via the railway corridor.",
icon: <ArrowDownToLine size={22} />,
},
{
type: "exporter",
label: "Exporter",
description: "Export goods from Ethiopia via rail.",
icon: <ArrowUpFromLine size={22} />,
},
];
const FORWARDER_ROLES: RoleOption[] = [
{
type: "freight_forwarder",
label: "Freight Forwarder",
description: "Handle cargo on behalf of importers and exporters.",
icon: <Building2 size={22} />,
},
];
// dj_freight_forwarder and transporter are intentionally not exposed yet.
function rolesForCompanyType(companyType: string): RoleOption[] {
if (companyType === "customer") return CUSTOMER_ROLES;
if (companyType === "freight_forwarder") return FORWARDER_ROLES;
return [];
}
import RoleCard from "./RoleCard";
import { rolesForCompanyType } from "./companyRoles";
interface CompanyRolesCardProps {
profile: ProfileResponse;
@@ -131,49 +84,19 @@ export default function CompanyRolesCard({ profile }: CompanyRolesCardProps) {
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
{options.map((opt) => {
const isActive = activeByType.has(opt.type);
const isSelected = selected.has(opt.type);
const highlighted = isActive || isSelected;
return (
<UnstyledButton
<RoleCard
key={opt.type}
label={opt.label}
description={opt.description}
icon={opt.icon}
selected={selected.has(opt.type)}
locked={isActive}
lockedNote={
isActive ? `Active · ${activeByType.get(opt.type)}` : undefined
}
onClick={() => toggle(opt.type)}
className={`group block rounded-lg border! p-5! text-left transition-all duration-200 ${
highlighted
? "border-[var(--mantine-color-edr-green-5)]! bg-edr-soft!"
: "border-edr-border! bg-edr-card! hover:-translate-y-0.5 hover:border-[var(--mantine-color-edr-green-5)]! hover:bg-edr-soft"
} ${isActive ? "cursor-default" : ""}`}
>
<Group gap="md" wrap="nowrap" align="start">
<ThemeIcon
size={56}
radius="lg"
variant={highlighted ? "filled" : "light"}
color="edr-green"
className="shrink-0"
>
{opt.icon}
</ThemeIcon>
<Box className="min-w-0 flex-1">
<Text fw={700} c="edr-text" fz={15}>
{opt.label}
</Text>
<Text size="xs" c="edr-muted" mt={4} lh={1.5}>
{opt.description}
</Text>
{isActive && (
<Text size="xs" c="edr-green" mt={6} fw={600}>
Active · {activeByType.get(opt.type)}
</Text>
)}
</Box>
{highlighted && (
<Check
size={18}
className="shrink-0 text-[var(--mantine-color-edr-green-6)]"
/>
)}
</Group>
</UnstyledButton>
/>
);
})}
</SimpleGrid>

View File

@@ -0,0 +1,77 @@
import { Card, Divider, Group, SimpleGrid, Text, Title } from "@mantine/core";
import { Building2 } from "lucide-react";
import RoleCard from "./RoleCard";
import { CUSTOMER_ROLES, FREIGHT_FORWARDER } from "./companyRoles";
interface OnboardingRoleSelectProps {
/** Currently selected profile types (e.g. ["importer"], ["importer","exporter"], ["freight_forwarder"]). */
value: string[];
onChange: (next: string[]) => void;
}
/**
* First (and only) thing shown in the Company Profile tab during onboarding.
* Importer / Exporter sit side by side and can both be picked; Freight
* Forwarder is a separate, mutually-exclusive choice below them. A valid
* selection reveals the company-profile fields.
*/
export default function OnboardingRoleSelect({
value,
onChange,
}: OnboardingRoleSelectProps) {
const selected = new Set(value);
const isForwarder = selected.has(FREIGHT_FORWARDER.type);
// Toggling a customer role drops any forwarder selection (mutually exclusive).
const toggleCustomerRole = (type: string) => {
const next = new Set(value.filter((t) => t !== FREIGHT_FORWARDER.type));
if (next.has(type)) next.delete(type);
else next.add(type);
onChange([...next]);
};
const toggleForwarder = () => {
onChange(isForwarder ? [] : [FREIGHT_FORWARDER.type]);
};
return (
<Card padding="lg">
<Group gap="sm" mb="xs">
<Building2 size={20} />
<Title order={3}>What does your company do?</Title>
</Group>
<Text c="edr-muted" size="sm" mb="lg">
Pick Importer, Exporter, or both or register as a Freight Forwarder.
</Text>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
{CUSTOMER_ROLES.map((role) => (
<RoleCard
key={role.type}
label={role.label}
description={role.description}
icon={role.icon}
selected={selected.has(role.type)}
onClick={() => toggleCustomerRole(role.type)}
/>
))}
</SimpleGrid>
<Divider
label="or"
labelPosition="center"
my="lg"
c="edr-muted"
styles={{ label: { textTransform: "uppercase", fontSize: 11 } }}
/>
<RoleCard
label={FREIGHT_FORWARDER.label}
description={FREIGHT_FORWARDER.description}
icon={FREIGHT_FORWARDER.icon}
selected={isForwarder}
onClick={toggleForwarder}
/>
</Card>
);
}

View File

@@ -0,0 +1,75 @@
import { Box, Group, Text, ThemeIcon, UnstyledButton } from "@mantine/core";
import { Check } from "lucide-react";
export interface RoleCardProps {
label: string;
description: string;
icon: React.ReactNode;
/** Highlighted because the user just selected it (toggleable). */
selected?: boolean;
/** Highlighted and non-interactive because it is already persisted. */
locked?: boolean;
/** Small note under the description, e.g. "Active · IM-00001". */
lockedNote?: string;
onClick?: () => void;
}
/**
* The selectable company-role card used by both the onboarding role picker and
* the add-only roles card in settings. Visual mirror of the onboarding
* account-type cards.
*/
export default function RoleCard({
label,
description,
icon,
selected = false,
locked = false,
lockedNote,
onClick,
}: RoleCardProps) {
const highlighted = selected || locked;
return (
<UnstyledButton
type="button"
onClick={locked ? undefined : onClick}
className={`group block rounded-lg border! p-5! text-left transition-all duration-200 ${
highlighted
? "border-[var(--mantine-color-edr-green-5)]! bg-edr-soft!"
: "border-edr-border! bg-edr-card! hover:-translate-y-0.5 hover:border-[var(--mantine-color-edr-green-5)]! hover:bg-edr-soft"
} ${locked ? "cursor-default" : ""}`}
>
<Group gap="md" wrap="nowrap" align="start">
<ThemeIcon
size={56}
radius="lg"
variant={highlighted ? "filled" : "light"}
color="edr-green"
className="shrink-0"
>
{icon}
</ThemeIcon>
<Box className="min-w-0 flex-1">
<Text fw={700} c="edr-text" fz={15}>
{label}
</Text>
<Text size="xs" c="edr-muted" mt={4} lh={1.5}>
{description}
</Text>
{lockedNote && (
<Text size="xs" c="edr-green" mt={6} fw={600}>
{lockedNote}
</Text>
)}
</Box>
{highlighted && (
<Check
size={18}
className="shrink-0 text-[var(--mantine-color-edr-green-6)]"
/>
)}
</Group>
</UnstyledButton>
);
}

View File

@@ -1,4 +1,4 @@
import { useMemo } from "react";
import { useMemo, useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
@@ -17,8 +17,12 @@ import {
import { api } from "@/services/api";
import PhoneInput from "@/components/auth/PhoneInput";
import type { ProfileResponse } from "@/types/profile";
import type { CreateCompanyPayload } from "@/services/companies.service";
import type {
CreateCompanyPayload,
CompanyProfileInput,
} from "@/services/companies.service";
import CompanyRolesCard from "./CompanyRolesCard";
import OnboardingRoleSelect from "./OnboardingRoleSelect";
export const COMPANY_PROFILE_SCHEMA = z.object({
companyName: z.string().min(1, "Company name is required"),
@@ -53,6 +57,7 @@ export default function TabCompanyProfile({
}: TabCompanyProfileProps) {
const queryClient = useQueryClient();
const isCreate = mode === "create";
const [selectedRoles, setSelectedRoles] = useState<string[]>([]);
const defaultValues = useMemo((): CompanyProfileFormData => {
if (profile) {
@@ -92,8 +97,7 @@ export default function TabCompanyProfile({
const mutation = useMutation({
mutationFn: async (data: CompanyProfileFormData) => {
const payload: CreateCompanyPayload = {
companyType: "customer",
const base = {
companyName: data.companyName,
companyEmail: data.companyEmail,
companyPhone: `${data.companyPhoneCountryCode}${data.companyPhone}`,
@@ -104,10 +108,21 @@ export default function TabCompanyProfile({
};
if (isCreate) {
// Importer/Exporter -> a "customer" company; Freight Forwarder is its
// own company type. Both drive the persisted CompanyProfile rows.
const companyType = selectedRoles.includes("freight_forwarder")
? "freight_forwarder"
: "customer";
const payload: CreateCompanyPayload = {
...base,
companyType,
companyProfiles: selectedRoles.map((type) => ({
type: type as CompanyProfileInput["type"],
})),
};
return api.companies.create.call(payload);
} else {
return api.companies.updateProfile.call(payload);
}
return api.companies.updateProfile.call(base);
},
onSuccess: () => {
queryClient.invalidateQueries({
@@ -119,11 +134,26 @@ export default function TabCompanyProfile({
},
});
const onSubmit = (data: CompanyProfileFormData) => mutation.mutate(data);
const onSubmit = (data: CompanyProfileFormData) => {
if (isCreate && selectedRoles.length === 0) return;
mutation.mutate(data);
};
// During onboarding the role selection gates the form: nothing else shows
// until the user picks Importer/Exporter or Freight Forwarder.
const showForm = !isCreate || selectedRoles.length > 0;
return (
<Stack gap="lg">
{!isCreate && profile && <CompanyRolesCard profile={profile} />}
{isCreate ? (
<OnboardingRoleSelect
value={selectedRoles}
onChange={setSelectedRoles}
/>
) : (
profile && <CompanyRolesCard profile={profile} />
)}
{showForm && (
<Card padding="lg">
<Group gap="sm" mb="xs">
<Building2 size={20} />
@@ -255,6 +285,7 @@ export default function TabCompanyProfile({
</Group>
</form>
</Card>
)}
</Stack>
);
}

View File

@@ -0,0 +1,39 @@
import { ArrowDownToLine, ArrowUpFromLine, Building2 } from "lucide-react";
export interface RoleMeta {
type: string;
label: string;
description: string;
icon: React.ReactNode;
}
export const IMPORTER: RoleMeta = {
type: "importer",
label: "Importer",
description: "Import goods into Ethiopia via the railway corridor.",
icon: <ArrowDownToLine size={22} />,
};
export const EXPORTER: RoleMeta = {
type: "exporter",
label: "Exporter",
description: "Export goods from Ethiopia via rail.",
icon: <ArrowUpFromLine size={22} />,
};
export const FREIGHT_FORWARDER: RoleMeta = {
type: "freight_forwarder",
label: "Freight Forwarder",
description: "Handle cargo on behalf of importers and exporters.",
icon: <Building2 size={22} />,
};
/** Importer / Exporter — the two roles a "customer" company can hold. */
export const CUSTOMER_ROLES: RoleMeta[] = [IMPORTER, EXPORTER];
// dj_freight_forwarder and transporter are intentionally not exposed yet.
export function rolesForCompanyType(companyType: string): RoleMeta[] {
if (companyType === "customer") return CUSTOMER_ROLES;
if (companyType === "freight_forwarder") return [FREIGHT_FORWARDER];
return [];
}