diff --git a/apps/edr-freight-api/src/modules/companies/companies.service.ts b/apps/edr-freight-api/src/modules/companies/companies.service.ts index dc68f1785..fe1bc5598 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.service.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.service.ts @@ -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 }; } diff --git a/apps/edr-freight-web/portal/src/pages/settings/CompanyRolesCard.tsx b/apps/edr-freight-web/portal/src/pages/settings/CompanyRolesCard.tsx index 8c448bfbd..000dcc25e 100644 --- a/apps/edr-freight-web/portal/src/pages/settings/CompanyRolesCard.tsx +++ b/apps/edr-freight-web/portal/src/pages/settings/CompanyRolesCard.tsx @@ -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: , - }, - { - type: "exporter", - label: "Exporter", - description: "Export goods from Ethiopia via rail.", - icon: , - }, -]; - -const FORWARDER_ROLES: RoleOption[] = [ - { - type: "freight_forwarder", - label: "Freight Forwarder", - description: "Handle cargo on behalf of importers and exporters.", - icon: , - }, -]; - -// 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) { {options.map((opt) => { const isActive = activeByType.has(opt.type); - const isSelected = selected.has(opt.type); - const highlighted = isActive || isSelected; return ( - 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" : ""}`} - > - - - {opt.icon} - - - - {opt.label} - - - {opt.description} - - {isActive && ( - - Active · {activeByType.get(opt.type)} - - )} - - {highlighted && ( - - )} - - + /> ); })} diff --git a/apps/edr-freight-web/portal/src/pages/settings/OnboardingRoleSelect.tsx b/apps/edr-freight-web/portal/src/pages/settings/OnboardingRoleSelect.tsx new file mode 100644 index 000000000..faa9e7377 --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/settings/OnboardingRoleSelect.tsx @@ -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 ( + + + + What does your company do? + + + Pick Importer, Exporter, or both — or register as a Freight Forwarder. + + + + {CUSTOMER_ROLES.map((role) => ( + toggleCustomerRole(role.type)} + /> + ))} + + + + + + + ); +} diff --git a/apps/edr-freight-web/portal/src/pages/settings/RoleCard.tsx b/apps/edr-freight-web/portal/src/pages/settings/RoleCard.tsx new file mode 100644 index 000000000..2e507ff83 --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/settings/RoleCard.tsx @@ -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 ( + + + + {icon} + + + + {label} + + + {description} + + {lockedNote && ( + + {lockedNote} + + )} + + {highlighted && ( + + )} + + + ); +} diff --git a/apps/edr-freight-web/portal/src/pages/settings/TabCompanyProfile.tsx b/apps/edr-freight-web/portal/src/pages/settings/TabCompanyProfile.tsx index 80e8d6ab7..f02740664 100644 --- a/apps/edr-freight-web/portal/src/pages/settings/TabCompanyProfile.tsx +++ b/apps/edr-freight-web/portal/src/pages/settings/TabCompanyProfile.tsx @@ -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([]); 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 ( - {!isCreate && profile && } + {isCreate ? ( + + ) : ( + profile && + )} + {showForm && ( @@ -255,6 +285,7 @@ export default function TabCompanyProfile({ + )} ); } diff --git a/apps/edr-freight-web/portal/src/pages/settings/companyRoles.tsx b/apps/edr-freight-web/portal/src/pages/settings/companyRoles.tsx new file mode 100644 index 000000000..d875d68af --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/settings/companyRoles.tsx @@ -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: , +}; + +export const EXPORTER: RoleMeta = { + type: "exporter", + label: "Exporter", + description: "Export goods from Ethiopia via rail.", + icon: , +}; + +export const FREIGHT_FORWARDER: RoleMeta = { + type: "freight_forwarder", + label: "Freight Forwarder", + description: "Handle cargo on behalf of importers and exporters.", + icon: , +}; + +/** 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 []; +}