mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
feat: add company selection to settings onboarding
This commit is contained in:
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
75
apps/edr-freight-web/portal/src/pages/settings/RoleCard.tsx
Normal file
75
apps/edr-freight-web/portal/src/pages/settings/RoleCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 [];
|
||||
}
|
||||
Reference in New Issue
Block a user