Merge pull request #222 from Tria-plc/freight/feat/company-profile

Freight/feat/company profile
This commit is contained in:
Nathnael Wondisha
2026-06-19 15:35:51 +03:00
committed by GitHub
10 changed files with 753 additions and 270 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

@@ -146,10 +146,11 @@ const sidebarItems: SidebarItem[] = [
const App = () => {
const navigate = useNavigate();
const location = useLocation();
const { user } = useAuth();
const { user, company } = useAuth();
const displayName = user?.name?.en || user?.username || user?.email || "User";
const userEmail = user?.email;
const companyProfiles = company?.company?.companyProfiles ?? [];
return (
<Routes>
@@ -190,6 +191,7 @@ const App = () => {
enableThemeToggle
userName={displayName}
userEmail={userEmail}
companyProfiles={companyProfiles}
>
<Outlet />
</AppLayout>

View File

@@ -47,9 +47,19 @@ export interface AppLayoutProps {
enableThemeToggle?: boolean;
userName?: string;
userEmail?: string;
/** Operational profiles for the company — surfaced as reference chips in the account menu. */
companyProfiles?: { type: string; reference: string; status?: string }[];
children: ReactNode;
}
const PROFILE_TYPE_LABELS: Record<string, string> = {
importer: "Importer",
exporter: "Exporter",
freight_forwarder: "Freight Forwarder",
dj_freight_forwarder: "DJ Freight Forwarder",
transporter: "Transporter",
};
function getInitials(name: string): string {
return name
.split(" ")
@@ -106,6 +116,7 @@ export function AppLayout({
enableThemeToggle = false,
userName = "User",
userEmail,
companyProfiles = [],
children,
}: AppLayoutProps) {
const [mobileOpen, { toggle: toggleMobile }] = useDisclosure();
@@ -306,6 +317,34 @@ export function AppLayout({
</Text>
)}
</Box>
{companyProfiles.length > 0 && (
<>
<Divider />
<Box px="sm" py="xs">
<Stack gap={6}>
{companyProfiles.map((p) => (
<Group
key={p.reference}
justify="space-between"
gap="sm"
wrap="nowrap"
>
<Text
size="xs"
fw={600}
style={{ color: textColor }}
>
{PROFILE_TYPE_LABELS[p.type] ?? p.type}
</Text>
<Text size="xs" ff="monospace" c="dimmed">
{p.reference}
</Text>
</Group>
))}
</Stack>
</Box>
</>
)}
<Divider />
<Menu.Item
leftSection={<User size={15} />}

View File

@@ -1,17 +1,113 @@
import { User, Building2, Phone, Mail, MapPin, ShieldCheck, Briefcase, UserCheck, Fingerprint, FileCheck, Globe, Building } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { api } from "@/services/api";
import { Card, CardHeader, CardTitle, CardDescription, CardContent, Badge, Separator } from "@edr/ui-common";
import {
Badge,
Box,
Button,
Card,
Center,
Container,
Divider,
Grid,
Group,
Loader,
SimpleGrid,
Stack,
Text,
ThemeIcon,
Title,
} from "@mantine/core";
import { useQuery } from "@tanstack/react-query";
import {
BadgeCheck,
Briefcase,
Building,
Building2,
FileCheck,
Globe,
Mail,
MapPin,
Phone,
Plus,
ShieldCheck,
User,
UserCheck,
} from "lucide-react";
import { Link } from "react-router-dom";
import { rolesForCompanyType } from "./settings/companyRoles";
function InfoItem({ icon, label, value }: { icon?: React.ReactNode; label: string; value?: string | null }) {
function InfoItem({
icon,
label,
value,
}: {
icon?: React.ReactNode;
label: string;
value?: string | null;
}) {
return (
<div className="flex items-start gap-3">
{icon && <div className="mt-1 text-muted-foreground [&_svg]:size-4">{icon}</div>}
<div className="flex flex-col gap-0.5">
<p className="text-[10px] font-bold uppercase tracking-tight text-muted-foreground">{label}</p>
<p className="text-sm font-bold text-foreground">{value || "—"}</p>
</div>
</div>
<Group gap="sm" align="flex-start" wrap="nowrap">
{icon && (
<ThemeIcon variant="light" color="edr-green" size="md" radius="md">
{icon}
</ThemeIcon>
)}
<Stack gap={2}>
<Text size="xs" fw={700} tt="uppercase" c="edr-muted">
{label}
</Text>
<Text size="sm" fw={600} c="edr-text">
{value || "—"}
</Text>
</Stack>
</Group>
);
}
function CardHeading({
icon,
title,
description,
}: {
icon: React.ReactNode;
title: string;
description: string;
}) {
return (
<Stack gap={2} mb="md">
<Group gap="sm">
{icon}
<Title order={4} size="h5">
{title}
</Title>
</Group>
<Text size="sm" c="edr-muted">
{description}
</Text>
</Stack>
);
}
function PersonnelGroup({
color,
title,
children,
}: {
color: string;
title: string;
children: React.ReactNode;
}) {
return (
<Stack gap="sm">
<Group gap="xs">
<Box w={4} h={16} bg={color} style={{ borderRadius: 2 }} />
<Text size="sm" fw={700} tt="uppercase" c="edr-text">
{title}
</Text>
</Group>
<Stack gap="sm" pl="lg">
{children}
</Stack>
</Stack>
);
}
@@ -22,163 +118,293 @@ export default function ProfilePage() {
if (isPending) {
return (
<div className="flex h-full items-center justify-center">
<div className="h-8 w-8 animate-spin rounded-full border-b-2 border-primary" />
</div>
<Center h="100%">
<Loader color="edr-green" size="lg" />
</Center>
);
}
if (!profile) {
return (
<div className="flex h-full items-center justify-center">
<p className="text-muted-foreground">No company profile found.</p>
</div>
<Center h="100%">
<Text c="edr-muted">No company profile found.</Text>
</Center>
);
}
// Registered operational profiles keyed by type, plus the roles this company
// type may hold (importer/exporter for a customer). Mirrors CompanyRolesCard.
const refByType = new Map(profile.companyProfiles.map((p) => [p.type, p]));
const roleOptions = rolesForCompanyType(profile.companyType);
const activeOptions = roleOptions.filter((o) => refByType.has(o.type));
return (
<div className="px-4 py-8">
<div className="mx-auto max-w-7xl">
<div className="flex flex-col gap-8">
{/* Header */}
<div className="flex items-center gap-6">
<div className="flex size-24 items-center justify-center rounded-2xl bg-primary/10 text-primary shadow-inner">
<User className="size-12" />
</div>
<div className="flex flex-col gap-1">
<div className="flex items-center gap-3">
<h1 className="text-3xl font-black tracking-tight text-foreground">
{profile.companyName}
</h1>
<Badge variant="secondary" className="font-bold uppercase tracking-wider">
Verified
<Container size="xl" px="lg" py="xl">
{/* Header */}
<Group gap="lg" align="center" mb="lg">
<ThemeIcon variant="light" color="edr-green" size={88} radius="lg">
<User size={44} />
</ThemeIcon>
<Stack gap={6}>
<Group gap="sm" align="center">
<Title order={1} size="h2">
{profile.companyName}
</Title>
<Badge color="edr-green" variant="light">
Verified
</Badge>
</Group>
{activeOptions.length > 0 ? (
<Group gap="xs">
{activeOptions.map((opt) => (
<Badge
key={opt.type}
variant="light"
color="edr-green"
size="lg"
radius="sm"
>
{opt.label} · {refByType.get(opt.type)!.reference}
</Badge>
</div>
<p className="flex items-center gap-2 font-medium text-muted-foreground">
<Building className="size-4" />
{profile.companyName}
</p>
</div>
</div>
))}
</Group>
) : (
<Group gap={6} c="edr-muted">
<Building size={16} />
<Text c="edr-muted" fw={500}>
{profile.companyType}
</Text>
</Group>
)}
</Stack>
</Group>
<Separator />
<Divider mb="lg" />
<div className="grid grid-cols-1 gap-8 lg:grid-cols-3">
{/* Left Column */}
<div className="flex flex-col gap-8 lg:col-span-2">
<div className="grid grid-cols-1 gap-8 md:grid-cols-2">
{/* Company Details */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Building2 className="size-5 text-primary" />
Company Details
</CardTitle>
<CardDescription>Business registration information</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<InfoItem icon={<Globe />} label="Location" value={profile.companyLocation} />
<InfoItem icon={<MapPin />} label="Address" value={profile.companyAddress} />
<InfoItem icon={<FileCheck />} label="TIN Number" value={profile.tinNumber} />
<InfoItem icon={<ShieldCheck />} label="FAN Number" value={profile.fanNumber} />
<InfoItem icon={<Mail />} label="Email" value={profile.companyEmail} />
<InfoItem icon={<Phone />} label="Phone" value={profile.companyPhone} />
</CardContent>
</Card>
<Grid gap="lg">
{/* Left Column */}
<Grid.Col span={{ base: 12, lg: 8 }}>
<Stack gap="lg">
{/* Company Details */}
<Card>
<CardHeading
icon={
<Building2
size={20}
color="var(--mantine-color-edr-green-6)"
/>
}
title="Company Details"
description="Business registration information"
/>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
<InfoItem
icon={<Globe size={16} />}
label="Location"
value={profile.companyLocation}
/>
<InfoItem
icon={<MapPin size={16} />}
label="Address"
value={profile.companyAddress}
/>
<InfoItem
icon={<FileCheck size={16} />}
label="TIN Number"
value={profile.tinNumber}
/>
<InfoItem
icon={<ShieldCheck size={16} />}
label="FAN Number"
value={profile.fanNumber}
/>
<InfoItem
icon={<Mail size={16} />}
label="Email"
value={profile.companyEmail}
/>
<InfoItem
icon={<Phone size={16} />}
label="Phone"
value={profile.companyPhone}
/>
</SimpleGrid>
</Card>
{/* Personal Details (from ExternalProfile) */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Fingerprint className="size-5 text-primary" />
Profile Details
</CardTitle>
<CardDescription>Your linked user profile</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<InfoItem icon={<User />} label="Profile" value="Primary Contact" />
</CardContent>
</Card>
</div>
{/* Key Personnel */}
<Card>
<CardHeading
icon={
<Briefcase
size={20}
color="var(--mantine-color-edr-green-6)"
/>
}
title="Key Personnel"
description="Management and contact persons"
/>
<SimpleGrid cols={{ base: 1, md: 2 }} spacing="lg">
<PersonnelGroup color="edr-green" title="Contact Person">
<InfoItem label="Name" value={profile.contactPersonName} />
<InfoItem label="Phone" value={profile.contactPersonPhone} />
</PersonnelGroup>
<PersonnelGroup color="edr-accent" title="General Manager">
<InfoItem label="Name" value={profile.generalManagerName} />
<InfoItem label="Email" value={profile.generalManagerEmail} />
<InfoItem label="Phone" value={profile.generalManagerPhone} />
</PersonnelGroup>
</SimpleGrid>
</Card>
{/* Personnel Card */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Briefcase className="size-5 text-primary" />
Key Personnel
</CardTitle>
<CardDescription>Management and contact persons</CardDescription>
</CardHeader>
<CardContent className="grid grid-cols-1 gap-8 md:grid-cols-2">
<div className="flex flex-col gap-4">
<h3 className="border-l-4 border-primary pl-3 text-sm font-bold uppercase tracking-wide text-foreground">
Contact Person
</h3>
<div className="flex flex-col gap-3 pl-4">
<InfoItem label="Name" value={profile.contactPersonName} />
<InfoItem label="Phone" value={profile.contactPersonPhone} />
</div>
</div>
<div className="flex flex-col gap-4">
<h3 className="border-l-4 border-accent pl-3 text-sm font-bold uppercase tracking-wide text-foreground">
General Manager
</h3>
<div className="flex flex-col gap-3 pl-4">
<InfoItem label="Name" value={profile.generalManagerName} />
<InfoItem label="Email" value={profile.generalManagerEmail} />
<InfoItem label="Phone" value={profile.generalManagerPhone} />
</div>
</div>
</CardContent>
{/* Power of Attorney */}
{profile.poaName && (
<Card style={{ borderStyle: "dashed" }}>
<CardHeading
icon={
<UserCheck
size={20}
color="var(--mantine-color-edr-accent-6)"
/>
}
title="Power of Attorney"
description="Authorized representative details"
/>
<SimpleGrid cols={{ base: 1, md: 2 }} spacing="md">
<InfoItem label="PoA Name" value={profile.poaName} />
<InfoItem label="PoA Email" value={profile.poaEmail} />
<InfoItem label="PoA Phone" value={profile.poaPhone} />
<InfoItem label="PoA Location" value={profile.poaLocation} />
</SimpleGrid>
</Card>
)}
</Stack>
</Grid.Col>
{/* Power of Attorney */}
{profile.poaName && (
<Card className="border-dashed">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<UserCheck className="size-5 text-accent" />
Power of Attorney
</CardTitle>
<CardDescription>Authorized representative details</CardDescription>
</CardHeader>
<CardContent className="grid grid-cols-1 gap-4 md:grid-cols-2">
<InfoItem label="PoA Name" value={profile.poaName} />
<InfoItem label="PoA Email" value={profile.poaEmail} />
<InfoItem label="PoA Phone" value={profile.poaPhone} />
<InfoItem label="PoA Location" value={profile.poaLocation} />
</CardContent>
</Card>
{/* Right Column */}
<Grid.Col span={{ base: 12, lg: 4 }}>
<Stack gap="lg">
{/* Operating Roles */}
<Card>
<CardHeading
icon={
<BadgeCheck
size={20}
color="var(--mantine-color-edr-green-6)"
/>
}
title="Operating Roles"
description="Your registered freight roles and reference numbers"
/>
{roleOptions.length === 0 ? (
<Text size="sm" c="edr-muted">
Role management for this company type is coming soon.
</Text>
) : (
<Stack gap="md">
{roleOptions.map((opt) => {
const active = refByType.get(opt.type);
return (
<Group
key={opt.type}
justify="space-between"
wrap="nowrap"
align="center"
>
<Group gap="sm" wrap="nowrap">
<ThemeIcon
variant="light"
color="edr-green"
size="lg"
radius="md"
>
{opt.icon}
</ThemeIcon>
<Stack gap={2}>
<Text size="sm" fw={600} c="edr-text">
{opt.label}
</Text>
<Text
size="xs"
c="edr-muted"
ff={active ? "monospace" : undefined}
>
{active ? active.reference : "Not registered"}
</Text>
</Stack>
</Group>
{active ? (
<Badge
variant="light"
color={
active.status === "active"
? "edr-green"
: "edr-accent"
}
tt="capitalize"
>
{active.status}
</Badge>
) : (
<Button
component={Link}
to="/settings?tab=company"
size="xs"
variant="light"
color="edr-green"
leftSection={<Plus size={14} />}
>
Add {opt.label}
</Button>
)}
</Group>
);
})}
</Stack>
)}
</div>
</Card>
{/* Right Column */}
<div className="flex flex-col gap-8">
<Card className="relative overflow-hidden border-none bg-foreground text-background shadow-xl">
<div className="absolute right-0 top-0 p-4 opacity-10">
<ShieldCheck className="size-32" />
</div>
<CardContent className="relative z-10 flex flex-col gap-4 px-6 py-8">
<h3 className="text-xl font-black">Secure Account</h3>
<p className="text-sm leading-relaxed text-muted-foreground/80">
Your information is protected by enterprise-grade security.
Contact support for verified information updates.
</p>
<div className="pt-2">
<a
href="/settings"
className="inline-flex h-9 items-center justify-center rounded-md bg-background px-4 text-sm font-medium text-foreground hover:bg-background/90"
>
Edit Settings
</a>
</div>
</CardContent>
</Card>
</div>
</div>
</div>
</div>
</div>
{/* Secure Account */}
<Card
padding="xl"
style={{
background: "var(--mantine-color-edr-ink-6)",
position: "relative",
overflow: "hidden",
}}
>
<Box
style={{
position: "absolute",
top: 16,
right: 16,
opacity: 0.1,
}}
>
<ShieldCheck size={128} color="white" />
</Box>
<Stack gap="md" style={{ position: "relative", zIndex: 1 }}>
<Title order={3} size="h4" c="white">
Secure Account
</Title>
<Text size="sm" c="gray.4">
Your information is protected by enterprise-grade security.
Contact support for verified information updates.
</Text>
<Button
component={Link}
to="/settings"
variant="white"
color="dark"
mt="xs"
w="fit-content"
>
Edit Settings
</Button>
</Stack>
</Card>
</Stack>
</Grid.Col>
</Grid>
</Container>
);
}

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

@@ -1,4 +1,16 @@
import { useState } from "react";
import { api } from "@/services/api";
import { companiesService } from "@/services/companies.service";
import { getMinFiles } from "@/types/fileUploadSettings";
import type { ProfileResponse } from "@/types/profile";
import { SmartFileInput } from "@edr/ui-common";
import {
Button,
Card,
Center,
Group,
Text,
Title,
} from "@mantine/core";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
ArrowRight,
@@ -8,18 +20,7 @@ import {
UploadCloud,
XCircle,
} from "lucide-react";
import {
Card,
Group,
Title,
Text,
Button,
Center,
} from "@mantine/core";
import { api } from "@/services/api";
import { companiesService } from "@/services/companies.service";
import { SmartFileInput } from "@edr/ui-common";
import type { ProfileResponse } from "@/types/profile";
import { useState } from "react";
interface TabDocumentsProps {
profile: ProfileResponse;
@@ -45,6 +46,42 @@ export default function TabDocuments({ profile, mode = "edit", onContinue }: Tab
},
});
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
const handleFilesChange = (next: Record<string, File | File[] | null>) => {
setDocumentFiles(next);
// Clear required-field errors for any field that now has a file.
setFieldErrors((prev) => {
if (Object.keys(prev).length === 0) return prev;
const updated = { ...prev };
for (const key of Object.keys(updated)) {
const v = next[key];
const hasValue = Array.isArray(v) ? v.length > 0 : v != null;
if (hasValue) delete updated[key];
}
return updated;
});
};
// Array-aware: an emptied multi-file field is `[]`, which must not count.
const hasFiles = Object.values(documentFiles).some((f) =>
Array.isArray(f) ? f.length > 0 : f != null,
);
const validateRequired = (): Record<string, string> => {
const errs: Record<string, string> = {};
for (const field of docSettingQuery.data?.fields ?? []) {
const min = getMinFiles(field);
if (min <= 0) continue;
const v = documentFiles[field.fileKey];
const count = Array.isArray(v) ? v.length : v ? 1 : 0;
if (count < min) {
errs[field.fileKey] = `${field.fileLabel} is required`;
}
}
return errs;
};
return (
<Card padding="lg">
<Group gap="sm" mb="xs">
@@ -67,7 +104,8 @@ export default function TabDocuments({ profile, mode = "edit", onContinue }: Tab
<SmartFileInput
file={docSettingQuery.data}
value={documentFiles}
onChange={setDocumentFiles}
onChange={handleFilesChange}
errors={fieldErrors}
/>
)}
@@ -100,13 +138,14 @@ export default function TabDocuments({ profile, mode = "edit", onContinue }: Tab
leftSection={<ArrowRight size={16} />}
loading={docUploadMutation.isPending}
onClick={() => {
const hasFiles = Object.values(documentFiles).some((f) => f !== null);
const validationErrors = validateRequired();
if (Object.keys(validationErrors).length > 0) {
setFieldErrors(validationErrors);
return;
}
if (hasFiles) {
docUploadMutation.mutate(documentFiles, {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: api.companies.getProfile.queryKey() });
onContinue?.();
},
onSuccess: () => onContinue?.(),
});
} else {
onContinue?.();
@@ -120,7 +159,11 @@ export default function TabDocuments({ profile, mode = "edit", onContinue }: Tab
type="button"
leftSection={<UploadCloud size={16} />}
loading={docUploadMutation.isPending}
onClick={() => docUploadMutation.mutate(documentFiles)}
disabled={!hasFiles}
onClick={() => {
if (!hasFiles) return;
docUploadMutation.mutate(documentFiles);
}}
>
Upload Documents
</Button>

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 [];
}