mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Merge pull request #222 from Tria-plc/freight/feat/company-profile
Freight/feat/company profile
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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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} />}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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