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,
|
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 };
|
return { company, profile };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,65 +1,18 @@
|
|||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { Building2, CheckCircle2, Save, XCircle } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
ArrowDownToLine,
|
|
||||||
ArrowUpFromLine,
|
|
||||||
Building2,
|
|
||||||
Check,
|
|
||||||
CheckCircle2,
|
|
||||||
Save,
|
|
||||||
XCircle,
|
|
||||||
} from "lucide-react";
|
|
||||||
import {
|
|
||||||
Box,
|
|
||||||
Button,
|
Button,
|
||||||
Card,
|
Card,
|
||||||
Group,
|
Group,
|
||||||
SimpleGrid,
|
SimpleGrid,
|
||||||
Text,
|
Text,
|
||||||
ThemeIcon,
|
|
||||||
Title,
|
Title,
|
||||||
UnstyledButton,
|
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { api } from "@/services/api";
|
import { api } from "@/services/api";
|
||||||
import type { ProfileResponse } from "@/types/profile";
|
import type { ProfileResponse } from "@/types/profile";
|
||||||
|
import RoleCard from "./RoleCard";
|
||||||
interface RoleOption {
|
import { rolesForCompanyType } from "./companyRoles";
|
||||||
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 [];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface CompanyRolesCardProps {
|
interface CompanyRolesCardProps {
|
||||||
profile: ProfileResponse;
|
profile: ProfileResponse;
|
||||||
@@ -131,49 +84,19 @@ export default function CompanyRolesCard({ profile }: CompanyRolesCardProps) {
|
|||||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
||||||
{options.map((opt) => {
|
{options.map((opt) => {
|
||||||
const isActive = activeByType.has(opt.type);
|
const isActive = activeByType.has(opt.type);
|
||||||
const isSelected = selected.has(opt.type);
|
|
||||||
const highlighted = isActive || isSelected;
|
|
||||||
return (
|
return (
|
||||||
<UnstyledButton
|
<RoleCard
|
||||||
key={opt.type}
|
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)}
|
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>
|
</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 { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
@@ -17,8 +17,12 @@ import {
|
|||||||
import { api } from "@/services/api";
|
import { api } from "@/services/api";
|
||||||
import PhoneInput from "@/components/auth/PhoneInput";
|
import PhoneInput from "@/components/auth/PhoneInput";
|
||||||
import type { ProfileResponse } from "@/types/profile";
|
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 CompanyRolesCard from "./CompanyRolesCard";
|
||||||
|
import OnboardingRoleSelect from "./OnboardingRoleSelect";
|
||||||
|
|
||||||
export const COMPANY_PROFILE_SCHEMA = z.object({
|
export const COMPANY_PROFILE_SCHEMA = z.object({
|
||||||
companyName: z.string().min(1, "Company name is required"),
|
companyName: z.string().min(1, "Company name is required"),
|
||||||
@@ -53,6 +57,7 @@ export default function TabCompanyProfile({
|
|||||||
}: TabCompanyProfileProps) {
|
}: TabCompanyProfileProps) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const isCreate = mode === "create";
|
const isCreate = mode === "create";
|
||||||
|
const [selectedRoles, setSelectedRoles] = useState<string[]>([]);
|
||||||
|
|
||||||
const defaultValues = useMemo((): CompanyProfileFormData => {
|
const defaultValues = useMemo((): CompanyProfileFormData => {
|
||||||
if (profile) {
|
if (profile) {
|
||||||
@@ -92,8 +97,7 @@ export default function TabCompanyProfile({
|
|||||||
|
|
||||||
const mutation = useMutation({
|
const mutation = useMutation({
|
||||||
mutationFn: async (data: CompanyProfileFormData) => {
|
mutationFn: async (data: CompanyProfileFormData) => {
|
||||||
const payload: CreateCompanyPayload = {
|
const base = {
|
||||||
companyType: "customer",
|
|
||||||
companyName: data.companyName,
|
companyName: data.companyName,
|
||||||
companyEmail: data.companyEmail,
|
companyEmail: data.companyEmail,
|
||||||
companyPhone: `${data.companyPhoneCountryCode}${data.companyPhone}`,
|
companyPhone: `${data.companyPhoneCountryCode}${data.companyPhone}`,
|
||||||
@@ -104,10 +108,21 @@ export default function TabCompanyProfile({
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (isCreate) {
|
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);
|
return api.companies.create.call(payload);
|
||||||
} else {
|
|
||||||
return api.companies.updateProfile.call(payload);
|
|
||||||
}
|
}
|
||||||
|
return api.companies.updateProfile.call(base);
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({
|
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 (
|
return (
|
||||||
<Stack gap="lg">
|
<Stack gap="lg">
|
||||||
{!isCreate && profile && <CompanyRolesCard profile={profile} />}
|
{isCreate ? (
|
||||||
|
<OnboardingRoleSelect
|
||||||
|
value={selectedRoles}
|
||||||
|
onChange={setSelectedRoles}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
profile && <CompanyRolesCard profile={profile} />
|
||||||
|
)}
|
||||||
|
{showForm && (
|
||||||
<Card padding="lg">
|
<Card padding="lg">
|
||||||
<Group gap="sm" mb="xs">
|
<Group gap="sm" mb="xs">
|
||||||
<Building2 size={20} />
|
<Building2 size={20} />
|
||||||
@@ -255,6 +285,7 @@ export default function TabCompanyProfile({
|
|||||||
</Group>
|
</Group>
|
||||||
</form>
|
</form>
|
||||||
</Card>
|
</Card>
|
||||||
|
)}
|
||||||
</Stack>
|
</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