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/App.tsx b/apps/edr-freight-web/portal/src/App.tsx index a4c2c8553..bbcdba42e 100644 --- a/apps/edr-freight-web/portal/src/App.tsx +++ b/apps/edr-freight-web/portal/src/App.tsx @@ -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 ( @@ -190,6 +191,7 @@ const App = () => { enableThemeToggle userName={displayName} userEmail={userEmail} + companyProfiles={companyProfiles} > diff --git a/apps/edr-freight-web/portal/src/components/AppLayout.tsx b/apps/edr-freight-web/portal/src/components/AppLayout.tsx index 8d00baeb6..e0de92ec5 100644 --- a/apps/edr-freight-web/portal/src/components/AppLayout.tsx +++ b/apps/edr-freight-web/portal/src/components/AppLayout.tsx @@ -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 = { + 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({ )} + {companyProfiles.length > 0 && ( + <> + + + + {companyProfiles.map((p) => ( + + + {PROFILE_TYPE_LABELS[p.type] ?? p.type} + + + {p.reference} + + + ))} + + + + )} } diff --git a/apps/edr-freight-web/portal/src/pages/ProfilePage.tsx b/apps/edr-freight-web/portal/src/pages/ProfilePage.tsx index 9c4354723..cf31d17a9 100644 --- a/apps/edr-freight-web/portal/src/pages/ProfilePage.tsx +++ b/apps/edr-freight-web/portal/src/pages/ProfilePage.tsx @@ -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 ( -
- {icon &&
{icon}
} -
-

{label}

-

{value || "—"}

-
-
+ + {icon && ( + + {icon} + + )} + + + {label} + + + {value || "—"} + + + + ); +} + +function CardHeading({ + icon, + title, + description, +}: { + icon: React.ReactNode; + title: string; + description: string; +}) { + return ( + + + {icon} + + {title} + + + + {description} + + + ); +} + +function PersonnelGroup({ + color, + title, + children, +}: { + color: string; + title: string; + children: React.ReactNode; +}) { + return ( + + + + + {title} + + + + {children} + + ); } @@ -22,163 +118,293 @@ export default function ProfilePage() { if (isPending) { return ( -
-
-
+
+ +
); } if (!profile) { return ( -
-

No company profile found.

-
+
+ No company profile found. +
); } + // 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 ( -
-
-
- {/* Header */} -
-
- -
-
-
-

- {profile.companyName} -

- - Verified + + {/* Header */} + + + + + + + + {profile.companyName} + + + Verified + + + {activeOptions.length > 0 ? ( + + {activeOptions.map((opt) => ( + + {opt.label} · {refByType.get(opt.type)!.reference} -
-

- - {profile.companyName} -

-
-
+ ))} + + ) : ( + + + + {profile.companyType} + + + )} + + - + -
- {/* Left Column */} -
-
- {/* Company Details */} - - - - - Company Details - - Business registration information - - - } label="Location" value={profile.companyLocation} /> - } label="Address" value={profile.companyAddress} /> - } label="TIN Number" value={profile.tinNumber} /> - } label="FAN Number" value={profile.fanNumber} /> - } label="Email" value={profile.companyEmail} /> - } label="Phone" value={profile.companyPhone} /> - - + + {/* Left Column */} + + + {/* Company Details */} + + + } + title="Company Details" + description="Business registration information" + /> + + } + label="Location" + value={profile.companyLocation} + /> + } + label="Address" + value={profile.companyAddress} + /> + } + label="TIN Number" + value={profile.tinNumber} + /> + } + label="FAN Number" + value={profile.fanNumber} + /> + } + label="Email" + value={profile.companyEmail} + /> + } + label="Phone" + value={profile.companyPhone} + /> + + - {/* Personal Details (from ExternalProfile) */} - - - - - Profile Details - - Your linked user profile - - - } label="Profile" value="Primary Contact" /> - - -
+ {/* Key Personnel */} + + + } + title="Key Personnel" + description="Management and contact persons" + /> + + + + + + + + + + + + - {/* Personnel Card */} - - - - - Key Personnel - - Management and contact persons - - -
-

- Contact Person -

-
- - -
-
-
-

- General Manager -

-
- - - -
-
-
+ {/* Power of Attorney */} + {profile.poaName && ( + + + } + title="Power of Attorney" + description="Authorized representative details" + /> + + + + + + + )} + + - {/* Power of Attorney */} - {profile.poaName && ( - - - - - Power of Attorney - - Authorized representative details - - - - - - - - + {/* Right Column */} + + + {/* Operating Roles */} + + + } + title="Operating Roles" + description="Your registered freight roles and reference numbers" + /> + {roleOptions.length === 0 ? ( + + Role management for this company type is coming soon. + + ) : ( + + {roleOptions.map((opt) => { + const active = refByType.get(opt.type); + return ( + + + + {opt.icon} + + + + {opt.label} + + + {active ? active.reference : "Not registered"} + + + + {active ? ( + + {active.status} + + ) : ( + + )} + + ); + })} + )} -
+ - {/* Right Column */} -
- -
- -
- -

Secure Account

-

- Your information is protected by enterprise-grade security. - Contact support for verified information updates. -

- -
-
-
-
-
-
-
+ {/* Secure Account */} + + + + + + + Secure Account + + + Your information is protected by enterprise-grade security. + Contact support for verified information updates. + + + + + + + + ); } 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/TabDocuments.tsx b/apps/edr-freight-web/portal/src/pages/settings/TabDocuments.tsx index 4425c513e..a6e48393a 100644 --- a/apps/edr-freight-web/portal/src/pages/settings/TabDocuments.tsx +++ b/apps/edr-freight-web/portal/src/pages/settings/TabDocuments.tsx @@ -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>({}); + + const handleFilesChange = (next: Record) => { + 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 => { + const errs: Record = {}; + 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 ( @@ -67,7 +104,8 @@ export default function TabDocuments({ profile, mode = "edit", onContinue }: Tab )} @@ -100,13 +138,14 @@ export default function TabDocuments({ profile, mode = "edit", onContinue }: Tab leftSection={} 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={} loading={docUploadMutation.isPending} - onClick={() => docUploadMutation.mutate(documentFiles)} + disabled={!hasFiles} + onClick={() => { + if (!hasFiles) return; + docUploadMutation.mutate(documentFiles); + }} > Upload Documents 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 []; +}