feat(companies): add Transit Agent service linked to transit-agent roster; forwarder/agent onboarding step, assigned-bookings tab, booking assignment + notify, drop agent validity window

This commit is contained in:
marshal
2026-09-06 21:41:29 +00:00
parent 6d4a919f39
commit 1eb9f10354
67 changed files with 2528 additions and 302 deletions

View File

@@ -1,6 +1,7 @@
import { AppLayout, type SidebarItem } from "@/components/AppLayout";
import { useDisclosure } from "@mantine/hooks";
import {
ClipboardList,
Home,
Layers,
LayoutDashboard,
@@ -74,6 +75,7 @@ import {
TransitAgentBookingDetailPage,
TransitAgentOverviewPage,
} from "./pages/transit-agent";
import { AssignedBookingsPage } from "./pages/forwarder";
import FaqPage from "./pages/support/FaqPage";
import HelpPage from "./pages/support/HelpPage";
import PrivacyPolicyPage from "./pages/support/PrivacyPolicyPage";
@@ -150,8 +152,13 @@ function isOnboardingAllowedPath(pathname: string): boolean {
* captured, so there is nothing for them to onboard — they go straight to home.
*/
function OnboardingGate() {
const { company, onboardingCompleted, isShippingLine, isTransitAgent } =
useAuth();
const {
company,
onboardingCompleted,
isShippingLine,
isTransitAgent,
isTransitAgentOnly,
} = useAuth();
const location = useLocation();
// Keyed off a positive shipping-line / transit-agent identification, never
@@ -186,6 +193,14 @@ function OnboardingGate() {
if (needsOnboarding && !allowedHere) {
return <Navigate to="/portal" replace />;
}
// Onboarded and nothing but a transit agent: the trade pages are not theirs.
if (
!needsOnboarding &&
isTransitAgentOnly &&
isTradeOnlyPath(location.pathname)
) {
return <Navigate to={ASSIGNED_BOOKINGS_PATH} replace />;
}
return (
<>
@@ -242,7 +257,8 @@ function RequireTransitAgent() {
* still in flight, which would land a shipping line on the customer home first.
*/
function useHomeRoute(): { ready: boolean; href: string } {
const { isShippingLine, isTransitAgent, customerQuery } = useAuth();
const { isShippingLine, isTransitAgent, isTransitAgentOnly, customerQuery } =
useAuth();
return {
ready: !customerQuery.isPending,
@@ -250,7 +266,9 @@ function useHomeRoute(): { ready: boolean; href: string } {
? "/shipping-line"
: isTransitAgent
? "/transit-agent"
: "/portal",
: isTransitAgentOnly
? ASSIGNED_BOOKINGS_PATH
: "/portal",
};
}
@@ -280,6 +298,30 @@ function LandingRoute() {
return <EDRFreightLandingPage />;
}
/** Where a transit agent / forwarder sees the bookings customers assign to it. */
const ASSIGNED_BOOKINGS_PATH = "/forwarder/assigned-bookings";
const ASSIGNED_BOOKINGS_ITEM: SidebarItem = {
label: "Assigned Bookings",
href: ASSIGNED_BOOKINGS_PATH,
icon: <ClipboardList size={18} />,
};
/**
* Pages a transit-agent-only company has no business on: they all show the
* company's OWN trade, and it has none. Visiting one lands on the list.
*/
const TRADE_ONLY_PATHS = [
"/portal",
"/contracts",
"/bookings",
"/billing",
"/tracking",
];
function isTradeOnlyPath(pathname: string): boolean {
const path = pathname.toLowerCase();
return TRADE_ONLY_PATHS.some((p) => path === p || path.startsWith(p + "/"));
}
const sidebarItems: SidebarItem[] = [
{ label: "Home", href: "/portal", icon: <Home size={18} /> },
{
@@ -377,6 +419,8 @@ const App = () => {
isAuthenticated,
isShippingLine,
isTransitAgent,
canSeeAssignedBookings,
isTransitAgentOnly,
} = useAuth();
// Attribute replays and exceptions to the signed-in user (id/org only).
@@ -397,6 +441,20 @@ const App = () => {
const displayName = user?.name?.en || user?.username || user?.email || "User";
const userEmail = user?.email;
const companyProfiles = company?.company?.companyProfiles ?? [];
// Only a roster company (transit agent / forwarder) has bookings assigned
// to it, so only it gets the tab — slotted right after Bookings, where it
// reads as more of the same. A transit-agent-only company has no contracts,
// bookings or invoices of its own, so those tabs go and the list leads.
const customerSidebarItems: SidebarItem[] = isTransitAgentOnly
? [
ASSIGNED_BOOKINGS_ITEM,
...sidebarItems.filter((i) => i.section === "Account"),
]
: canSeeAssignedBookings
? sidebarItems.flatMap((item) =>
item.href === "/bookings" ? [item, ASSIGNED_BOOKINGS_ITEM] : [item],
)
: sidebarItems;
return (
<>
@@ -562,14 +620,24 @@ const App = () => {
element={
<AppLayout
title="EDR Freight"
sidebarItems={sidebarItems}
sidebarItems={customerSidebarItems}
activeHref={location.pathname}
onNavigate={navigate}
userName={displayName}
userEmail={userEmail}
companyProfiles={companyProfiles}
companyType={companyType}
onCreateProfile={createProfile}
companyTransitAgentId={
company?.company?.transitAgentId ?? null
}
onCreateProfile={(type, files, options) =>
createProfile(
type,
files,
undefined,
options?.transitAgentId,
)
}
onReapplyProfile={reapplyProfile}
>
<OnboardingGate />
@@ -601,6 +669,12 @@ const App = () => {
path="/bookings/:id/contract"
element={<BookingContractPage />}
/>
{/* Freight forwarders only: bookings customers assigned to
the transit agent this company registered as. */}
<Route
path={ASSIGNED_BOOKINGS_PATH}
element={<AssignedBookingsPage />}
/>
<Route path="/contracts" element={<ContractsList />} />
<Route path="/contracts/new" element={<NewContractPage />} />
<Route

View File

@@ -33,6 +33,7 @@ import {
X,
} from "lucide-react";
import { Fragment, type ReactNode, useState } from "react";
import TransitAgentSelect from "@/components/onboarding/TransitAgentSelect";
import { PROFILE_TYPE_LABELS } from "@/constants/profileMode";
import NotificationBellContainer from "@/features/notifications/NotificationBellContainer";
import SupportWidget from "@/features/support/SupportWidget";
@@ -67,6 +68,12 @@ export interface AppLayoutProps {
}[];
/** Company type (e.g. "customer", "forwarder") — gates the "Add service" control. */
companyType?: string | null;
/**
* The transit-agent roster entry the company already registered as, if any.
* Adding a roster role (transit agent / forwarder) asks for it only when
* this is null — the link is per company, not per role.
*/
companyTransitAgentId?: string | null;
/**
* Render the floating support-chat launcher. Defaults to true so the customer
* portal is unaffected; shipping lines pass false — support chat is scoped to
@@ -77,6 +84,7 @@ export interface AppLayoutProps {
onCreateProfile?: (
type: ServiceType,
licenseFiles: File[],
options?: { transitAgentId?: string },
) => Promise<SwitchResult> | void;
/** Resubmit a rejected service for approval, optionally replacing its license. */
onReapplyProfile?: (
@@ -87,14 +95,22 @@ export interface AppLayoutProps {
}
/** Service profiles a customer company can operate under and switch between. */
type ServiceType = "importer" | "exporter" | "freight_forwarder";
type ServiceType =
| "importer"
| "exporter"
| "freight_forwarder"
| "transit_agent";
/** Services a customer company can select in the header. */
const CUSTOMER_SERVICES: ServiceType[] = [
"importer",
"exporter",
"freight_forwarder",
"transit_agent",
];
/** The roster roles — identified by a transit-agent entry, not a licence. */
const AGENT_SERVICES: ServiceType[] = ["freight_forwarder", "transit_agent"];
type SwitchResult =
| { success: true; data?: unknown }
| { success: false; error?: { message?: string } };
@@ -156,6 +172,7 @@ export function AppLayout({
userEmail,
companyProfiles = [],
companyType,
companyTransitAgentId = null,
onCreateProfile,
onReapplyProfile,
showSupportWidget = true,
@@ -222,6 +239,9 @@ export function AppLayout({
// Reason the profile was suspended/rejected, surfaced in the modal.
const [reapplyNote, setReapplyNote] = useState<string | null>(null);
const [licenseFiles, setLicenseFiles] = useState<File[]>([]);
// The roster entry picked for a transit agent / forwarder service, when the
// company has not named one yet.
const [transitAgentId, setTransitAgentId] = useState<string | null>(null);
const [createError, setCreateError] = useState<string | null>(null);
const openServiceModal = (
@@ -233,25 +253,40 @@ export function AppLayout({
setReapplyStatus(profile?.status ?? null);
setReapplyNote(profile?.reviewNote ?? null);
setLicenseFiles([]);
setTransitAgentId(null);
setCreateError(null);
setCreateOpen(true);
};
const handleAddService = (type: ServiceType) => openServiceModal(type);
// A transit agent is identified by its roster entry, not a licence.
const licenceApplies = createTarget !== "transit_agent";
// A roster role needs the entry named once per company.
const agentNeeded =
AGENT_SERVICES.includes(createTarget) && !companyTransitAgentId;
const handleCreateConfirm = async () => {
const isReapply = reapplyId !== null;
// A new profile needs its license up front; a resubmit may reuse the old one.
if (!isReapply && licenseFiles.length === 0) {
if (!isReapply && licenceApplies && licenseFiles.length === 0) {
setCreateError("Please upload at least one business license file.");
return;
}
if (!isReapply && agentNeeded && !transitAgentId) {
setCreateError("Pick your company from the transit agent list.");
return;
}
setSwitching(true);
setCreateError(null);
try {
const res = isReapply
? await onReapplyProfile?.(reapplyId, licenseFiles)
: await onCreateProfile?.(createTarget, licenseFiles);
: await onCreateProfile?.(
createTarget,
licenseFiles,
transitAgentId ? { transitAgentId } : undefined,
);
if (res && !res.success) {
setCreateError(res.error?.message ?? "Failed to submit service");
return;
@@ -896,9 +931,11 @@ export function AppLayout({
? `Your ${serviceLabel(
createTarget,
).toLowerCase()} service was rejected. Replace the business license if needed, then resubmit for approval.`
: `You don't have a ${serviceLabel(
createTarget,
).toLowerCase()} profile yet. Add your business license to create one — it goes to EDR for approval before you can operate under it.`}
: createTarget === "transit_agent"
? "You don't have a transit agent profile yet. Pick your company from EDR's transit agent list to create one — it goes to EDR for approval before bookings assigned to you can be worked on."
: `You don't have a ${serviceLabel(
createTarget,
).toLowerCase()} profile yet. Add your business license to create one — it goes to EDR for approval before you can operate under it.`}
</Text>
{isSuspendedAppeal && reapplyNote && (
<Alert
@@ -910,19 +947,33 @@ export function AppLayout({
{reapplyNote}
</Alert>
)}
<FileInput
label={
reapplyId ? "Business license (optional)" : "Business license"
}
multiple
clearable
accept="application/pdf,image/png,image/jpeg"
leftSection={<Upload size={16} />}
placeholder="Select license file(s)"
value={licenseFiles}
onChange={(files) => setLicenseFiles(files ?? [])}
error={createError ?? undefined}
/>
{!reapplyId && agentNeeded ? (
<TransitAgentSelect
value={transitAgentId}
onChange={setTransitAgentId}
disabled={switching}
error={createError && !transitAgentId ? createError : undefined}
/>
) : null}
{licenceApplies ? (
<FileInput
label={
reapplyId ? "Business license (optional)" : "Business license"
}
multiple
clearable
accept="application/pdf,image/png,image/jpeg"
leftSection={<Upload size={16} />}
placeholder="Select license file(s)"
value={licenseFiles}
onChange={(files) => setLicenseFiles(files ?? [])}
error={createError ?? undefined}
/>
) : createError && transitAgentId ? (
<Text size="sm" c="red">
{createError}
</Text>
) : null}
<Group justify="flex-end" gap="sm">
<Button
variant="default"

View File

@@ -19,6 +19,7 @@ import {
Globe2,
PartyPopper,
ShieldCheck,
Truck,
UploadCloud,
User,
UserCheck,
@@ -27,6 +28,7 @@ import type { ReactNode } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import type { RoleLicenseProfile } from "@/components/onboarding/RoleLicenseStep";
import TransitAgentSelect from "@/components/onboarding/TransitAgentSelect";
import useAuth from "@/hooks/useAuth";
import CompanyProfileForm from "@/pages/accounts/CompanyProfileForm";
import NationalitySelect from "@/pages/settings/NationalitySelect";
@@ -57,9 +59,37 @@ const FORM_STEPS: FormStep[] = [
"documents",
];
/** The full onboarding journey: the two pre-form phases + the form steps. */
type WizardStep = "nationality-role" | FormStep;
const WIZARD_STEPS: WizardStep[] = ["nationality-role", ...FORM_STEPS];
/**
* The full onboarding journey: the pre-form phases + the form steps. The
* transit-agent phase exists only for a company taking a roster role, and a
* company that is ONLY a transit agent stops right after it — see
* {@link wizardStepsFor}.
*/
type WizardStep = "nationality-role" | "transit-agent" | FormStep;
/**
* A transit agent or forwarder answers one more question before the form;
* nobody else sees it. A transit-agent-only company has no form at all: its
* registration IS the roster entry it picks.
*/
function wizardStepsFor(
needsAgent: boolean,
transitAgentOnly: boolean,
): WizardStep[] {
if (transitAgentOnly) return ["nationality-role", "transit-agent"];
return needsAgent
? ["nationality-role", "transit-agent", ...FORM_STEPS]
: ["nationality-role", ...FORM_STEPS];
}
const FORWARDER_ROLE = "freight_forwarder";
const TRANSIT_AGENT_ROLE = "transit_agent";
/** The roles that are an Ethiopian transit-agent roster entry. */
const AGENT_ROLES = [FORWARDER_ROLE, TRANSIT_AGENT_ROLE];
const needsAgentFor = (roles: string[]) =>
roles.some((r) => AGENT_ROLES.includes(r));
const isTransitAgentOnlyRoles = (roles: string[]) =>
roles.length > 0 && roles.every((r) => r === TRANSIT_AGENT_ROLE);
/** Icon + title + description shown in the global dialog header per step. */
const STEP_META: Record<
@@ -71,6 +101,12 @@ const STEP_META: Record<
title: "Tell us about your company",
description: "This determines the documents we'll ask you to provide.",
},
"transit-agent": {
icon: <Truck size={20} />,
title: "Your transit agent registration",
description:
"Transit agents and freight forwarders are registered with EDR as Ethiopian transit agents — pick your company from the list.",
},
company: {
icon: <Building2 size={20} />,
title: "Company Information",
@@ -140,6 +176,7 @@ export default function OnboardingWizardDialog({
const hasOperationalProfiles = existingProfiles.length > 0;
const savedNationality =
(company?.company?.nationality as CompanyNationality | null) ?? null;
const savedTransitAgentId = company?.company?.transitAgentId ?? null;
// Resume position from the backend-persisted step.
const resumeFormStep: FormStep = FORM_STEPS.includes(
@@ -148,17 +185,23 @@ export default function OnboardingWizardDialog({
? (onboardingStep as FormStep)
: "company";
// Phases: nationality → role → form. If a draft already exists, resume
// straight into the form with nationality + roles pre-selected.
const [phase, setPhase] = useState<"nationality-role" | "form">(
companyAlreadyStarted ? "form" : "nationality-role",
);
// Phases: nationality → role → (transit agent, forwarders only) → form. If a
// draft already exists, resume straight into the form with nationality +
// roles pre-selected.
const [phase, setPhase] = useState<
"nationality-role" | "transit-agent" | "form"
>(companyAlreadyStarted ? "form" : "nationality-role");
const [nationality, setNationality] = useState<CompanyNationality | null>(
savedNationality,
);
const [roles, setRoles] = useState<string[]>(
existingProfiles.map((p) => p.type),
);
// Which Ethiopian transit agent the company is — asked only of a forwarder,
// and required before its draft can be created (the API refuses otherwise).
const [transitAgentId, setTransitAgentId] = useState<string | null>(
savedTransitAgentId,
);
const [cooperative, setCooperative] = useState<boolean>(
company?.company?.attributes?.cooperative === true,
);
@@ -175,7 +218,7 @@ export default function OnboardingWizardDialog({
const handleCooperativeChange = useCallback((checked: boolean) => {
setCooperative(checked);
if (checked) {
setRoles((prev) => prev.filter((r) => r !== "freight_forwarder"));
setRoles((prev) => prev.filter((r) => !AGENT_ROLES.includes(r)));
// Ethiopian is then the only answer left, so it is made rather than asked.
setNationality("ethiopian");
// Which also rules out the investment licence — that is a foreign
@@ -244,8 +287,9 @@ export default function OnboardingWizardDialog({
nationality?: CompanyNationality;
cooperative?: boolean;
investorLicence?: boolean;
transitAgentId?: string;
}) => api.companies.startOnboarding.call(vars),
onSuccess: async () => {
onSuccess: async (_data, vars) => {
// Nationality drives the server-resolved identity requirements (Fayda vs
// passport), the document set and the PoA copy — all read from
// onboardingRequirements/profile. Re-entering role selection can change
@@ -260,6 +304,13 @@ export default function OnboardingWizardDialog({
queryKey: api.companies.getProfile.queryKey(),
}),
]);
// A transit-agent-only company is done: the API marked its onboarding
// complete on this very call, so there is no form to go to — straight
// to the closing panel (kept open by `completed` once the gate lets go).
if (isTransitAgentOnlyRoles(vars.roles)) {
setCompleted(true);
return;
}
setPhase("form");
},
onError: (err) => setStartError(extractApiError(err).message),
@@ -324,28 +375,62 @@ export default function OnboardingWizardDialog({
useEffect(() => {
if (!companyAlreadyStarted || resumedRef.current) return;
resumedRef.current = true;
setRoles(existingProfiles.map((p) => p.type));
const savedRoles = existingProfiles.map((p) => p.type);
setRoles(savedRoles);
setNationality(savedNationality);
setTransitAgentId(savedTransitAgentId);
setCooperative(company?.company?.attributes?.cooperative === true);
setInvestorLicence(company?.company?.attributes?.investorLicence === true);
// Resume into the form only when profiles exist; otherwise send the user to
// role selection so the missing operational profiles get created.
setPhase(hasOperationalProfiles ? "form" : "nationality-role");
// role selection so the missing operational profiles get created. A
// roster-role draft that predates the transit-agent link lands on that
// question instead, since the API will not let it finish without one.
const agentUnlinked = needsAgentFor(savedRoles) && !savedTransitAgentId;
setPhase(
!hasOperationalProfiles
? "nationality-role"
: agentUnlinked
? "transit-agent"
: "form",
);
const idx = FORM_STEPS.indexOf(resumeFormStep);
if (idx > furthestIdxRef.current) furthestIdxRef.current = idx;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [companyAlreadyStarted, resumeFormStep]);
const needsAgent = needsAgentFor(roles);
const transitAgentOnly = isTransitAgentOnlyRoles(roles);
const startDraft = useCallback(
(agentId: string | null) => {
setStartError(null);
startMutation.mutate({
companyType: companyTypeForRoles(roles),
roles: roles as ProfileTypeValue[],
nationality: nationality ?? undefined,
cooperative,
investorLicence,
...(agentId ? { transitAgentId: agentId } : {}),
});
},
[roles, nationality, cooperative, investorLicence, startMutation],
);
// A roster role has one more question before its draft exists; everyone
// else goes straight to the draft.
const handleRolesContinue = useCallback(() => {
setStartError(null);
startMutation.mutate({
companyType: companyTypeForRoles(roles),
roles: roles as ProfileTypeValue[],
nationality: nationality ?? undefined,
cooperative,
investorLicence,
});
}, [roles, nationality, cooperative, investorLicence, startMutation]);
if (needsAgent) {
setPhase("transit-agent");
return;
}
startDraft(null);
}, [needsAgent, startDraft]);
const handleTransitAgentContinue = useCallback(() => {
if (!transitAgentId) return;
startDraft(transitAgentId);
}, [transitAgentId, startDraft]);
// Back from the form's first step returns to nationality/role selection.
// Safe to re-enter: startOnboarding is idempotent — it reuses the existing
@@ -422,8 +507,12 @@ export default function OnboardingWizardDialog({
const effectiveNationality: CompanyNationality =
nationality ?? savedNationality ?? "ethiopian";
// Per-role license cards for the final step (from the created profiles).
const roleProfiles: RoleLicenseProfile[] = existingProfiles.map((p) => ({
// Per-role license cards for the final step (from the created profiles). A
// transit agent holds no licence here — its roster entry is its
// registration — so it gets no card.
const roleProfiles: RoleLicenseProfile[] = existingProfiles
.filter((p) => p.type !== TRANSIT_AGENT_ROLE)
.map((p) => ({
id: p.id,
type: p.type,
reference: p.reference,
@@ -432,9 +521,10 @@ export default function OnboardingWizardDialog({
}));
// The active step across the whole journey, driving the header + progress pill.
const wizardSteps = wizardStepsFor(needsAgent, transitAgentOnly);
const activeStep: WizardStep = phase === "form" ? formStep : phase;
const stepMeta = STEP_META[activeStep];
const activeIdx = WIZARD_STEPS.indexOf(activeStep);
const activeIdx = wizardSteps.indexOf(activeStep);
// Prefer the backend-resolved document code; fall back to the local mapping
// only until the requirements query lands (the documents step is reached well
@@ -566,13 +656,16 @@ export default function OnboardingWizardDialog({
{stepMeta.description}
</Text>
</Box>
<ProgressPill current={activeIdx} total={WIZARD_STEPS.length} />
<ProgressPill current={activeIdx} total={wizardSteps.length} />
</Stack>
)
}
>
{completed ? (
<OnboardingCompletePanel onClose={handleClose} />
<OnboardingCompletePanel
onClose={handleClose}
transitAgentOnly={transitAgentOnly}
/>
) : (
<Stack gap="xl">
{phase === "nationality-role" ? (
@@ -622,9 +715,9 @@ export default function OnboardingWizardDialog({
value={roles}
onChange={setRoles}
embedded
// Forwarding is licensed work — a co-op holds no licence, so
// the role is not offered rather than refused later.
excludeTypes={cooperative ? ["freight_forwarder"] : undefined}
// Forwarding and transit work are licensed — a co-op holds no
// licence, so the roles are not offered rather than refused later.
excludeTypes={cooperative ? AGENT_ROLES : undefined}
/>
{startError && (
<Text size="sm" c="red">
@@ -647,6 +740,44 @@ export default function OnboardingWizardDialog({
</Button>
</Group>
</Stack>
) : phase === "transit-agent" ? (
<Stack gap="lg">
<TransitAgentSelect
value={transitAgentId}
onChange={setTransitAgentId}
disabled={startMutation.isPending}
/>
{startError && (
<Text size="sm" c="red">
{startError}
</Text>
)}
<Group justify="space-between" pt="xs">
<Button
variant="default"
onClick={() => {
setStartError(null);
setPhase("nationality-role");
}}
disabled={startMutation.isPending}
>
Back
</Button>
<Button
color="edr-green"
onClick={handleTransitAgentContinue}
disabled={!transitAgentId}
loading={startMutation.isPending}
rightSection={
startMutation.isPending ? undefined : (
<ArrowRight size={16} />
)
}
>
Continue
</Button>
</Group>
</Stack>
) : (
<CompanyProfileForm {...formProps} />
)}
@@ -661,7 +792,14 @@ export default function OnboardingWizardDialog({
* and sets the expectation that their company is now under review, and that
* bookings unlock per profile as the team approves each one.
*/
function OnboardingCompletePanel({ onClose }: { onClose: () => void }) {
function OnboardingCompletePanel({
onClose,
transitAgentOnly = false,
}: {
onClose: () => void;
/** No documents were asked for: the roster entry is the whole application. */
transitAgentOnly?: boolean;
}) {
return (
<Stack gap="lg" align="center" py="md" ta="center">
<Box
@@ -677,8 +815,9 @@ function OnboardingCompletePanel({ onClose }: { onClose: () => void }) {
<Box>
<Title order={3}>You're all set!</Title>
<Text c="edr-muted" size="sm" mt={4} maw={460}>
Thanks for completing your company profile. Your application has been
submitted and is now with our team for review.
{transitAgentOnly
? "Your transit agent registration has been submitted and is now with our team for review."
: "Thanks for completing your company profile. Your application has been submitted and is now with our team for review."}
</Text>
</Box>
@@ -696,8 +835,9 @@ function OnboardingCompletePanel({ onClose }: { onClose: () => void }) {
className="mt-0.5 shrink-0 text-[var(--mantine-color-edr-green-7)]"
/>
<Text size="sm" ta="left">
Each operational profile (importer, exporter, freight forwarder) is
reviewed and approved individually.
{transitAgentOnly
? "Once approved, bookings that customers assign to you appear under Assigned Bookings, and you can upload their clearance documents there."
: "Each operational profile (importer, exporter, freight forwarder, transit agent) is reviewed and approved individually."}
</Text>
</Group>
<Group gap="sm" wrap="nowrap" align="flex-start">
@@ -706,14 +846,15 @@ function OnboardingCompletePanel({ onClose }: { onClose: () => void }) {
className="mt-0.5 shrink-0 text-[var(--mantine-color-edr-green-7)]"
/>
<Text size="sm" ta="left">
You can start creating bookings under a profile as soon as it's
approved we'll let you know the moment that happens.
{transitAgentOnly
? "You can see assigned bookings right away — we'll let you know the moment your registration is approved."
: "You can start creating bookings under a profile as soon as it's approved — we'll let you know the moment that happens."}
</Text>
</Group>
</Stack>
<Button color="edr-green" size="md" onClick={onClose} mt="xs">
Continue to Dashboard
{transitAgentOnly ? "Go to Assigned Bookings" : "Continue to Dashboard"}
</Button>
</Stack>
);

View File

@@ -17,6 +17,7 @@ const ROLE_LABELS: Record<string, string> = {
freight_forwarder: "Freight Forwarder",
dj_freight_forwarder: "DJ Freight Forwarder",
transporter: "Transporter",
transit_agent: "Transit Agent",
};
/** Field key the synthesized per-profile upload setting is keyed on. */

View File

@@ -0,0 +1,158 @@
import {
Alert,
Anchor,
Button,
Loader,
Select,
Stack,
Text,
} from "@mantine/core";
import { useQuery } from "@tanstack/react-query";
import { AlertCircle, Phone, SearchX } from "lucide-react";
import { useMemo, useState } from "react";
import { usePortalContent } from "@/hooks/usePortalContent";
import { api } from "@/services/api";
interface TransitAgentSelectProps {
/** The transit agent picked, if any. */
value: string | null;
onChange: (transitAgentId: string | null) => void;
disabled?: boolean;
/** Defaults read as onboarding ("which agent is your company?"). */
label?: string;
description?: string;
error?: string;
/** What to do when the agent is not listed. */
notFoundHint?: string;
}
/**
* Which Ethiopian transit agent a freight forwarder IS.
*
* A forwarder and an Ethiopian transit agent are one business, so the role is
* taken by picking the company's own roster entry rather than by typing a
* name — that is what later lets GL assign work to the same row the customer
* signs contracts under. The roster is staff-maintained, so a company that
* is not on it cannot add itself: the fallback is the support line, shown
* on request rather than up front so it does not read as the expected path.
*/
export default function TransitAgentSelect({
value,
onChange,
disabled,
label = "Which transit agent is your company? *",
description = "Freight forwarders are registered with EDR as Ethiopian transit agents. Search for your company's name as it appears on your transit licence.",
error,
notFoundHint = "Once support adds you, come back here and pick your company to continue.",
}: TransitAgentSelectProps) {
const { data, isLoading, isError, refetch } = useQuery({
...api.companies.forwarderTransitAgents.queryOptions(),
staleTime: 5 * 60 * 1000,
retry: 1,
});
const { data: content } = usePortalContent();
const [notFound, setNotFound] = useState(false);
const options = useMemo(
() => (data ?? []).map((a) => ({ value: a.id, label: a.name })),
[data],
);
const supportPhone = content?.contact.phone ?? "";
const supportPhoneTel = supportPhone.replace(/\s+/g, "");
const supportEmail = content?.contact.email ?? "";
if (isLoading) {
return (
<Stack gap={4}>
<Text size="sm" c="edr-muted">
Loading the transit agent list
</Text>
<Loader size="sm" color="edr-green" />
</Stack>
);
}
if (isError) {
return (
<Alert color="yellow" icon={<AlertCircle size={16} />}>
We couldn't load the transit agent list.{" "}
<Anchor component="button" type="button" onClick={() => refetch()}>
Try again
</Anchor>
.
</Alert>
);
}
return (
<Stack gap="sm">
<Select
label={label}
description={description}
error={error}
placeholder={
options.length === 0
? "No transit agents are listed yet"
: "Type to search by company name"
}
data={options}
value={value}
onChange={(v) => {
onChange(v);
if (v) setNotFound(false);
}}
disabled={disabled || options.length === 0}
searchable
clearable
nothingFoundMessage="No transit agent matches that name"
maxDropdownHeight={280}
comboboxProps={{ withinPortal: true }}
/>
<div>
<Button
variant="subtle"
color="edr-green"
size="compact-sm"
leftSection={<SearchX size={14} />}
onClick={() => setNotFound((open) => !open)}
aria-expanded={notFound}
>
I didn't find the transit agent
</Button>
</div>
{notFound && (
<Alert
color="edr-green"
variant="light"
icon={<Phone size={16} />}
title="Not on the list? Ask support to register you"
>
<Text size="sm">
Only transit agents registered by EDR appear here. Call{" "}
{supportPhone ? (
<Anchor href={`tel:${supportPhoneTel}`} fw={600}>
{supportPhone}
</Anchor>
) : (
"support"
)}
{supportEmail && (
<>
{" "}
or email{" "}
<Anchor href={`mailto:${supportEmail}`} fw={600}>
{supportEmail}
</Anchor>
</>
)}{" "}
with the company name and transit licence number. {notFoundHint}
</Text>
</Alert>
)}
</Stack>
);
}

View File

@@ -226,6 +226,10 @@ export const URL_CONSTANTS = {
},
// Public — no session required; the sign-up screen links to these pages.
TRANSIT_AGENTS_API: {
/** Active Ethiopian transit agents (id + name) a forwarder can register as. */
FORWARDER_OPTIONS: "/api/transit-agents/forwarder-options",
},
PORTAL_CONTENT: {
PUBLIC: "/api/support-content",
},

View File

@@ -9,4 +9,5 @@ export const PROFILE_TYPE_LABELS: Record<string, string> = {
freight_forwarder: "Freight Forwarder",
dj_freight_forwarder: "DJ Freight Forwarder",
transporter: "Transporter",
transit_agent: "Transit Agent",
};

View File

@@ -213,8 +213,30 @@ const useAuth = () => {
// as long as they have at least one backoffice-approved operational role.
const companyProfiles = companyInfo?.company?.companyProfiles ?? [];
const hasActiveProfile = companyProfiles.some((p) => p.status === "active");
// The roster roles: a transit agent, or a freight forwarder (which is one
// too). A company holding either, and linked to its roster entry, sees the
// bookings customers assign to it. The tab shows from the moment the role is
// requested — that is how it learns work is waiting — but acting on them
// waits for approval (the API enforces the same split).
const agentProfiles = companyProfiles.filter(
(p) => p.type === "transit_agent" || p.type === "freight_forwarder",
);
const canSeeAssignedBookings =
agentProfiles.length > 0 && Boolean(companyInfo?.company?.transitAgentId);
const assignedBookingsUnlocked = agentProfiles.some(
(p) => p.status === "active",
);
// A company that does nothing but act as a transit agent has no contracts,
// bookings or invoices of its own: its portal is the assigned-bookings list.
const isTransitAgentOnly =
companyProfiles.length > 0 &&
companyProfiles.every((p) => p.type === "transit_agent");
const hasPendingProfile = companyProfiles.some((p) => p.status === "pending");
const canBook = hasActiveProfile;
// Booking needs a TRADE role in service — the transit agent role carries no
// bookings of its own.
const canBook = companyProfiles.some(
(p) => p.status === "active" && p.type !== "transit_agent",
);
// Profile-edit review: while a change request is pending the customer is
// locked out of editing and of creating new contracts/bookings; a rejected
@@ -249,11 +271,14 @@ const useAuth = () => {
* by the API for any company that has an eTrade record.
*/
licenceNumber?: string,
/** The roster entry, for the transit agent / forwarder roles. */
transitAgentId?: string,
): Promise<Result<void>> => {
try {
const created = await api.companies.createCompanyProfile.call({
type,
licenceNumber,
transitAgentId,
});
if (licenseFiles.length > 0) {
await companiesService.uploadProfileLicense(created.id, licenseFiles);
@@ -316,6 +341,9 @@ const useAuth = () => {
canBook,
hasActiveProfile,
hasPendingProfile,
canSeeAssignedBookings,
assignedBookingsUnlocked,
isTransitAgentOnly,
companyType,
companyStatus,
isCompanyApproved,

View File

@@ -426,6 +426,7 @@ const ROLE_LABELS: Record<string, string> = {
exporter: "Exporter",
freight_forwarder: "Freight Forwarder",
dj_freight_forwarder: "DJ Freight Forwarder",
transit_agent: "Transit Agent",
transporter: "Transporter",
};

View File

@@ -10,6 +10,7 @@ import { useForm, Controller } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useNavigate, useParams } from "react-router-dom";
import TransitAgentSelect from "@/components/onboarding/TransitAgentSelect";
import {
ActionIcon,
Alert,
@@ -23,6 +24,7 @@ import {
Loader,
Modal,
Paper,
SegmentedControl,
Stack,
Switch,
Text,
@@ -630,15 +632,22 @@ function NewShipmentBookingForm({
? { requestedWagons: Number(values.requestedWagons) }
: {}),
}),
// Customer's own clearing agent — collected at completion; the server
// requires all three for a without-customs import/export booking.
...(values.customsClearingAgent?.trim()
? {
customsClearingAgent: values.customsClearingAgent.trim(),
customsClearingAgentEmail: values.customsClearingAgentEmail.trim(),
customsClearingAgentPhone: values.customsClearingAgentPhone.trim(),
}
: {}),
// Who clears customs — collected at completion of a without-customs
// import/export booking. Either a registered transit agent (the booking
// is assigned to that forwarder) or the customer's own agent, for which
// the server requires all three fields. Never both.
...(values.clearingAgentMode === "transit_agent" &&
values.transitAgentId?.trim()
? { transitAgentId: values.transitAgentId.trim() }
: values.customsClearingAgent?.trim()
? {
customsClearingAgent: values.customsClearingAgent.trim(),
customsClearingAgentEmail:
values.customsClearingAgentEmail.trim(),
customsClearingAgentPhone:
values.customsClearingAgentPhone.trim(),
}
: {}),
...(values.notes ? { notes: values.notes } : {}),
};
}
@@ -2077,18 +2086,66 @@ function EquipmentReturnStep({ form }: { form: ShipmentForm }) {
/**
* Completion of a without-customs import/export booking: the customer names
* their own customs clearing agent per booking — name, email and phone are
* all required (the schema and the server both enforce it).
* who clears customs for it, one of two ways. Pick a registered Ethiopian
* transit agent — a freight forwarder on the platform, which then gets the
* booking in its own work list and a notice — or type their own agent's name,
* email and phone (all required; the schema and the server both enforce it).
*/
function ClearingAgentStep({ form }: { form: ShipmentForm }) {
const mode = form.watch("clearingAgentMode");
return (
<StepCard>
<StepHeader
icon={<FileText size={22} />}
title="Customs Clearing Agent"
description="Your service does not include customs clearance — enter the agent handling customs for this booking."
description="Your service does not include customs clearance — tell us who handles customs for this booking."
/>
<Stack gap="sm">
<Controller
name="clearingAgentMode"
control={form.control}
render={({ field }) => (
<SegmentedControl
fullWidth
radius={10}
color="edr-green"
value={field.value}
onChange={(v) => {
field.onChange(v);
// Switching clears the other option so only one is ever sent.
if (v === "transit_agent") {
form.setValue("customsClearingAgent", "", { shouldDirty: true });
form.setValue("customsClearingAgentEmail", "", { shouldDirty: true });
form.setValue("customsClearingAgentPhone", "", { shouldDirty: true });
} else {
form.setValue("transitAgentId", "", { shouldDirty: true });
}
}}
data={[
{ value: "transit_agent", label: "Registered transit agent" },
{ value: "manual", label: "Enter agent details" },
]}
/>
)}
/>
{mode === "transit_agent" ? (
<Controller
name="transitAgentId"
control={form.control}
render={({ field, fieldState }) => (
<TransitAgentSelect
value={field.value || null}
onChange={(v) => field.onChange(v ?? "")}
label="Transit agent *"
description="Registered Ethiopian transit agents (freight forwarders). The booking is assigned to the one you pick and they are notified."
error={fieldState.error?.message}
notFoundHint="Only transit agents registered with EDR are listed. Ask your forwarder to register, or switch to entering their details instead."
/>
)}
/>
) : null}
{mode === "manual" ? (
<>
<Controller
name="customsClearingAgent"
control={form.control}
@@ -2135,6 +2192,8 @@ function ClearingAgentStep({ form }: { form: ShipmentForm }) {
)}
/>
</Group>
</>
) : null}
</Stack>
</StepCard>
);

View File

@@ -119,6 +119,10 @@ const shipmentFormBase = z.object({
customsClearingAgent: z.string().default(""),
customsClearingAgentEmail: z.string().default(""),
customsClearingAgentPhone: z.string().default(""),
// The other way to name who clears customs: a registered Ethiopian transit
// agent (a freight forwarder on the platform). One of the two, never both.
clearingAgentMode: z.enum(["manual", "transit_agent"]).default("manual"),
transitAgentId: z.string().default(""),
notes: z.string().default(""),
});
@@ -146,7 +150,15 @@ export function createShipmentFormSchema(ctx: ShipmentValidationContext) {
});
}
if (ctx.requiresClearingAgent) {
if (ctx.requiresClearingAgent && data.clearingAgentMode === "transit_agent") {
if (!data.transitAgentId.trim()) {
refineCtx.addIssue({
code: "custom",
path: ["transitAgentId"],
message: "Pick the transit agent handling customs for this booking.",
});
}
} else if (ctx.requiresClearingAgent) {
if (!data.customsClearingAgent.trim()) {
refineCtx.addIssue({
code: "custom",
@@ -407,6 +419,8 @@ export const initialShipmentFormValues: DeepPartial<ShipmentFormValues> = {
customsClearingAgent: "",
customsClearingAgentEmail: "",
customsClearingAgentPhone: "",
clearingAgentMode: "manual",
transitAgentId: "",
notes: "",
};

View File

@@ -0,0 +1,445 @@
import {
Alert,
Badge,
Box,
Button,
Card,
Group,
Pagination,
Select,
Stack,
Text,
TextInput,
Title,
} from "@mantine/core";
import { useDebouncedValue } from "@mantine/hooks";
import { keepPreviousData, useQuery } from "@tanstack/react-query";
import {
DataTable,
usePagination,
type ColumnDef,
type DataTableFooterProps,
} from "@edr/ui-common";
import {
Building2,
ClipboardList,
Clock3,
Inbox,
PackageCheck,
Paperclip,
RefreshCw,
Search,
ShipWheel,
Truck,
X,
} from "lucide-react";
import { useMemo, useState } from "react";
import useAuth from "@/hooks/useAuth";
import {
transitAssignmentsService,
type TransitAssignment,
type TransitAssignmentStatus,
} from "@/services/transit-assignments.service";
const headerCell =
"whitespace-nowrap text-[10px] font-semibold uppercase tracking-[0.08em] text-edr-muted";
const prettyStatus = (s?: string | null) =>
(s ?? "")
.toLowerCase()
.replace(/_/g, " ")
.replace(/^\w/, (c) => c.toUpperCase());
const STATUS_META: Record<
TransitAssignmentStatus,
{ label: string; color: string }
> = {
NOT_STARTED: { label: "Not started", color: "gray" },
IN_PROGRESS: { label: "In progress", color: "blue" },
FINISHED: { label: "Finished", color: "edr-green" },
};
const STATUS_OPTIONS = [
{ value: "NOT_STARTED", label: "Not started" },
{ value: "IN_PROGRESS", label: "In progress" },
{ value: "FINISHED", label: "Finished" },
];
function shipmentColor(status?: string | null): string {
switch (status) {
case "DISPATCHED":
return "teal";
case "SCHEDULED":
return "blue";
case "MANUAL_ONLY":
return "orange";
default:
return "gray";
}
}
function formatDate(value?: string | null): string {
if (!value) return "—";
const d = new Date(value);
return Number.isNaN(d.getTime())
? "—"
: d.toLocaleDateString(undefined, {
day: "2-digit",
month: "short",
year: "numeric",
});
}
/** DataTable footer: row range left, rows-per-page + pager right. */
function TablePager<T>({ table, pagination }: DataTableFooterProps<T>) {
const pageIndex = pagination.pageIndex ?? 0;
const pageSize = pagination.pageSize ?? 10;
const total = pagination.totalCount ?? 0;
const pageCount = Math.max(
1,
pagination.pageCount ?? Math.ceil(total / pageSize),
);
const start = total === 0 ? 0 : pageIndex * pageSize + 1;
const end = Math.min((pageIndex + 1) * pageSize, total);
return (
<Group
justify="space-between"
gap="sm"
wrap="wrap"
px="md"
py={10}
style={{ borderTop: "1px solid var(--mantine-color-edr-divider-6)" }}
>
<Text fz={12} c="edr-muted">
Showing {start}{end} of {total} bookings
</Text>
<Group gap="sm" wrap="nowrap">
<Group gap={6} wrap="nowrap">
<Text fz={12} c="edr-muted">
Rows
</Text>
<Select
size="xs"
w={70}
radius="md"
value={String(pageSize)}
data={["10", "25", "50"]}
onChange={(v) => v && table.setPageSize(Number(v))}
allowDeselect={false}
comboboxProps={{ withinPortal: true }}
aria-label="Rows per page"
/>
</Group>
<Pagination
size="sm"
radius="md"
color="edr-ink"
total={pageCount}
value={pageIndex + 1}
onChange={(p) => table.setPageIndex(p - 1)}
/>
</Group>
</Group>
);
}
/**
* The freight forwarder's work list: bookings customers assigned to the
* transit agent this company registered itself as, read through the same
* `/transit-assignments/my` endpoint the Djibouti transit officer uses.
*
* List only for now — no detail page. The forwarder sees the work from the
* moment its role is requested; documents and other actions unlock once the
* role is approved, which the banner says.
*/
export default function AssignedBookingsPage() {
const { assignedBookingsUnlocked } = useAuth();
const [query, setQuery] = useState("");
const [debouncedQuery] = useDebouncedValue(query.trim(), 300);
const [status, setStatus] = useState<TransitAssignmentStatus | null>(null);
const { pagination, setPagination } = usePagination({ pageSize: 10 });
const { data, isPending, isError, error, isFetching, refetch } = useQuery({
queryKey: [
"transit-assignments",
{
search: debouncedQuery,
status,
page: pagination.pageIndex + 1,
pageSize: pagination.pageSize,
},
],
queryFn: () =>
transitAssignmentsService.list({
search: debouncedQuery || undefined,
status: status ?? undefined,
page: pagination.pageIndex + 1,
pageSize: pagination.pageSize,
}),
placeholderData: keepPreviousData,
});
const items = data?.items ?? [];
const total = data?.meta?.total ?? 0;
const pageCount = data?.meta?.totalPages ?? 1;
const hasFilters = Boolean(debouncedQuery) || Boolean(status);
const columns: ColumnDef<TransitAssignment, unknown>[] = useMemo(
() => [
{
id: "booking",
header: () => <span className={headerCell}>Booking</span>,
cell: ({ row }) => {
const r = row.original;
return (
<div className="flex items-center gap-2.5 py-1">
<div className="flex size-[30px] shrink-0 items-center justify-center rounded-[9px] bg-edr-divider text-edr-muted">
<PackageCheck size={15} strokeWidth={1.75} />
</div>
<div className="min-w-0">
<Text fz={13} fw={600} c="edr-text">
{r.booking?.reference ?? "—"}
</Text>
<Group gap={4} wrap="nowrap" align="flex-start">
<Building2
size={10}
className="mt-[3px] shrink-0 text-edr-muted opacity-70"
/>
<Text fz={11} c="edr-muted">
{r.customerName ?? "—"}
</Text>
</Group>
</div>
</div>
);
},
},
{
id: "shipment",
header: () => <span className={headerCell}>Shipment</span>,
cell: ({ row }) => {
const b = row.original.booking;
const isImport = b?.tradeDirection === "IMPORT";
const Icon = isImport ? Truck : ShipWheel;
return (
<Stack gap={5} py={2}>
<Badge
size="sm"
variant="light"
radius="sm"
color={shipmentColor(b?.schedulingStatus)}
>
{prettyStatus(b?.schedulingStatus) || "—"}
</Badge>
{b?.tradeDirection ? (
<span
className="inline-flex w-fit items-center gap-1 rounded-[5px] px-1.5 py-[2px] text-[10px] font-medium leading-none"
style={{
background: `var(--mantine-color-${isImport ? "blue" : "teal"}-0)`,
color: `var(--mantine-color-${isImport ? "blue" : "teal"}-7)`,
}}
>
<Icon size={10} />
{prettyStatus(b.tradeDirection)}
</span>
) : null}
</Stack>
);
},
},
{
id: "status",
header: () => <span className={headerCell}>Status</span>,
cell: ({ row }) => {
const m = STATUS_META[row.original.status];
return (
<Badge size="sm" variant="light" radius="sm" color={m.color}>
{m.label}
</Badge>
);
},
},
{
id: "assignedAt",
header: () => <span className={headerCell}>Assigned</span>,
cell: ({ row }) => (
<Group gap={6} wrap="nowrap">
<Clock3 size={12} className="shrink-0 text-edr-muted" />
<Text fz={12} c="edr-text">
{formatDate(row.original.assignedAt)}
</Text>
</Group>
),
},
{
id: "documents",
header: () => <span className={headerCell}>Documents</span>,
cell: ({ row }) => (
<Group gap={6} wrap="nowrap">
<Paperclip size={12} className="shrink-0 text-edr-muted" />
<Text fz={12} c="edr-text">
{row.original.files?.length ?? 0}
</Text>
</Group>
),
},
],
[],
);
return (
<Box>
<Stack gap="lg">
<Group justify="space-between" align="flex-start" wrap="wrap">
<Group gap="sm" align="center">
<div className="flex size-10 items-center justify-center rounded-xl bg-edr-soft text-edr-green-7">
<ClipboardList size={20} />
</div>
<Box>
<Title order={2}>Assigned Bookings</Title>
<Text c="edr-muted" size="sm">
Bookings customers have assigned to you for customs clearance.
</Text>
</Box>
</Group>
<Button
variant="light"
color="gray"
size="compact-sm"
radius="md"
leftSection={<RefreshCw size={14} />}
loading={isFetching && !isPending}
onClick={() => void refetch()}
>
Refresh
</Button>
</Group>
{!assignedBookingsUnlocked ? (
<Alert color="yellow" variant="light" radius="md">
Your transit agent registration is still under review. You can see
the bookings assigned to you, but uploading documents and other
actions unlock once it is approved.
</Alert>
) : null}
<Card
withBorder
shadow="sm"
radius="lg"
p={0}
className="overflow-hidden"
>
<Group
gap="sm"
wrap="wrap"
px="md"
py={12}
style={{
borderBottom: "1px solid var(--mantine-color-edr-divider-6)",
}}
>
<TextInput
value={query}
onChange={(e) => {
setQuery(e.currentTarget.value);
setPagination((p) => ({ ...p, pageIndex: 0 }));
}}
placeholder="Search by booking reference or customer"
leftSection={<Search size={14} />}
rightSection={
query ? (
<button
type="button"
aria-label="Clear search"
className="text-edr-muted"
onClick={() => setQuery("")}
>
<X size={14} />
</button>
) : null
}
radius="md"
size="sm"
w={{ base: "100%", sm: 320 }}
/>
<Select
value={status}
onChange={(v) => {
setStatus((v as TransitAssignmentStatus | null) ?? null);
setPagination((p) => ({ ...p, pageIndex: 0 }));
}}
data={STATUS_OPTIONS}
placeholder="All statuses"
clearable
radius="md"
size="sm"
w={{ base: "100%", sm: 180 }}
comboboxProps={{ withinPortal: true }}
/>
</Group>
{!isPending && !isError && items.length === 0 ? (
<Stack align="center" gap="xs" py={48}>
<Inbox size={28} className="text-edr-muted" />
<Text fw={600} c="edr-text">
{hasFilters ? "No bookings match" : "No bookings assigned yet"}
</Text>
<Text fz={13} c="edr-muted" ta="center" maw={420}>
{hasFilters
? "Try a different reference or clear the filters."
: "When a customer picks your company as the transit agent on a booking, it shows up here and you are notified."}
</Text>
{hasFilters ? (
<Button
variant="light"
color="gray"
size="compact-sm"
radius="md"
onClick={() => {
setQuery("");
setStatus(null);
}}
>
Clear filters
</Button>
) : null}
</Stack>
) : (
<Box w="100%" miw={0}>
<DataTable<TransitAssignment, unknown>
columns={columns}
data={items}
status={isPending ? "loading" : isError ? "error" : "success"}
error={
isError
? {
message: (error as Error).message,
onRetry: () => void refetch(),
}
: undefined
}
pagination={{
pageIndex: pagination.pageIndex,
pageSize: pagination.pageSize,
pageCount,
totalCount: total,
}}
tableOptions={{
state: { pagination },
onPaginationChange: setPagination,
manualPagination: true,
pageCount,
}}
containerClassName="border-0 shadow-none rounded-none bg-transparent"
footer={(p) => <TablePager {...p} />}
/>
</Box>
)}
</Card>
</Stack>
</Box>
);
}

View File

@@ -0,0 +1 @@
export { default as AssignedBookingsPage } from "./AssignedBookingsPage";

View File

@@ -15,6 +15,12 @@ import EtradeBusinessSelect, {
businessLabel,
useEtradeBusinesses,
} from "@/components/onboarding/EtradeBusinessSelect";
import TransitAgentSelect from "@/components/onboarding/TransitAgentSelect";
import useAuth from "@/hooks/useAuth";
import { AGENT_ROLE_TYPES } from "./companyRoles";
const isAgentRole = (type: string) =>
(AGENT_ROLE_TYPES as readonly string[]).includes(type);
import type { CompanyProfileResponse } from "@/services/companies.service";
import type { ProfileResponse } from "@/types/profile";
import RoleCard from "./RoleCard";
@@ -52,6 +58,11 @@ function roleStatusView(p: CompanyProfileResponse): {
export default function CompanyRolesCard({ profile }: CompanyRolesCardProps) {
const queryClient = useQueryClient();
// The roster entry the company already named, if any. A roster role added
// here asks for it only when there is none — the link is per company.
const { company } = useAuth();
const linkedTransitAgentId = company?.company?.transitAgentId ?? null;
const [transitAgentId, setTransitAgentId] = useState<string | null>(null);
const options = useMemo(
() => rolesForCompanyType(profile.companyType),
@@ -115,11 +126,13 @@ export default function CompanyRolesCard({ profile }: CompanyRolesCardProps) {
profiles: types.map((type) => ({
type,
licenceNumber: licenceByType[type],
...(isAgentRole(type) && transitAgentId ? { transitAgentId } : {}),
})),
}),
onSuccess: () => {
setSelected(new Set());
setLicenceByType({});
setTransitAgentId(null);
queryClient.invalidateQueries({
queryKey: api.companies.getProfile.queryKey(),
});
@@ -145,14 +158,22 @@ export default function CompanyRolesCard({ profile }: CompanyRolesCardProps) {
},
});
// Every selected role needs its business named first — the API rejects a role
// added without one, so the button is what tells the user, not a 400.
// Every selected licensed role needs its business named first — the API
// rejects a role added without one, so the button is what tells the user,
// not a 400. A transit agent holds no business: its roster entry is its
// registration.
const missingLicence =
businessRequired &&
Array.from(selected).some((type) => !licenceByType[type]);
Array.from(selected).some(
(type) => type !== "transit_agent" && !licenceByType[type],
);
// A roster role needs the transit agent named, once per company.
const agentRequired =
!linkedTransitAgentId && Array.from(selected).some(isAgentRole);
const missingAgent = agentRequired && !transitAgentId;
const handleSave = () => {
if (selected.size === 0 || missingLicence) return;
if (selected.size === 0 || missingLicence || missingAgent) return;
mutation.mutate(Array.from(selected));
};
@@ -164,7 +185,7 @@ export default function CompanyRolesCard({ profile }: CompanyRolesCardProps) {
</Group>
<Text c="edr-muted" size="sm" mb="lg">
{profile.companyType === "customer"
? "Select the service(s) your company operates as — importer, exporter and/or freight forwarder."
? "Select the service(s) your company operates as — importer, exporter, freight forwarder and/or transit agent."
: "Your company's operational role."}
</Text>
@@ -252,7 +273,9 @@ export default function CompanyRolesCard({ profile }: CompanyRolesCardProps) {
Say which of your eTrade business licences each new role operates as.
</Text>
{options
.filter((opt) => selected.has(opt.type))
.filter(
(opt) => selected.has(opt.type) && opt.type !== "transit_agent",
)
.map((opt) => (
<EtradeBusinessSelect
key={opt.type}
@@ -269,6 +292,16 @@ export default function CompanyRolesCard({ profile }: CompanyRolesCardProps) {
</Stack>
)}
{agentRequired && (
<Stack gap="sm" mt="lg">
<TransitAgentSelect
value={transitAgentId}
onChange={setTransitAgentId}
disabled={mutation.isPending}
/>
</Stack>
)}
{options.length > 0 && (
<Group
justify="space-between"
@@ -298,7 +331,7 @@ export default function CompanyRolesCard({ profile }: CompanyRolesCardProps) {
type="button"
leftSection={<Save size={16} />}
loading={mutation.isPending}
disabled={selected.size === 0 || missingLicence}
disabled={selected.size === 0 || missingLicence || missingAgent}
onClick={handleSave}
>
{selected.size > 1 ? "Add Roles" : "Add Role"}

View File

@@ -49,6 +49,7 @@ const ROLE_LABELS: Record<string, string> = {
exporter: "Exporter",
freight_forwarder: "Freight Forwarder",
dj_freight_forwarder: "DJ Freight Forwarder",
transit_agent: "Transit Agent",
transporter: "Transporter",
};

View File

@@ -1,4 +1,9 @@
import { ArrowDownToLine, ArrowUpFromLine, Building2 } from "lucide-react";
import {
ArrowDownToLine,
ArrowUpFromLine,
Building2,
Truck,
} from "lucide-react";
export interface RoleMeta {
type: string;
@@ -28,12 +33,29 @@ export const FREIGHT_FORWARDER: RoleMeta = {
icon: <Building2 size={22} />,
};
export const TRANSIT_AGENT: RoleMeta = {
type: "transit_agent",
label: "Transit Agent",
description:
"Clear customs for bookings that importers and exporters assign to you.",
icon: <Truck size={22} />,
};
/** The roles that are an Ethiopian transit-agent roster entry. */
export const AGENT_ROLE_TYPES = ["freight_forwarder", "transit_agent"] as const;
/**
* Importer / Exporter / Freight Forwarder — the services a "customer" company
* can hold. A single company may register for any combination, each getting its
* own business license.
* Importer / Exporter / Freight Forwarder / Transit Agent — the services a
* "customer" company can hold. A single company may register for any
* combination; the licensed ones each get their own business licence, the
* transit agent is identified by its roster entry instead.
*/
export const CUSTOMER_ROLES: RoleMeta[] = [IMPORTER, EXPORTER, FREIGHT_FORWARDER];
export const CUSTOMER_ROLES: RoleMeta[] = [
IMPORTER,
EXPORTER,
FREIGHT_FORWARDER,
TRANSIT_AGENT,
];
// dj_freight_forwarder and transporter are intentionally not exposed yet.
export function rolesForCompanyType(companyType: string): RoleMeta[] {

View File

@@ -60,6 +60,7 @@ import type {
ChangeRequestResponse,
CompanyDocument,
CompanyInfoResponse,
ForwarderTransitAgentOption,
CompanyNationality,
CompanyProfileResponse,
LicenseFile,
@@ -220,12 +221,23 @@ export const api = {
),
addCompanyProfiles: endpoint<
{ profiles: { type: string; licenceNumber?: string }[] },
{
profiles: {
type: string;
licenceNumber?: string;
transitAgentId?: string;
}[];
},
CompanyProfileResponse[]
>("companies", "addCompanyProfiles", companiesService.addCompanyProfiles),
createCompanyProfile: endpoint<
{ type: ProfileTypeValue; businessLicense?: string; licenceNumber?: string },
{
type: ProfileTypeValue;
businessLicense?: string;
licenceNumber?: string;
transitAgentId?: string;
},
CompanyProfileResponse
>(
"companies",
@@ -257,10 +269,18 @@ export const api = {
cooperative?: boolean;
/** Foreign investment licence: registration typed, no eTrade lookup. */
investorLicence?: boolean;
/** Which Ethiopian transit agent the company is — required with the forwarder role. */
transitAgentId?: string;
},
CompanyInfoResponse
>("companies", "startOnboarding", companiesService.startOnboarding),
forwarderTransitAgents: endpoint<void, ForwarderTransitAgentOption[]>(
"companies",
"forwarderTransitAgents",
companiesService.listForwarderTransitAgents,
),
revertToRegularCompany: endpoint<void, CompanyInfoResponse>(
"companies",
"revertToRegularCompany",

View File

@@ -12,7 +12,8 @@ export type ProfileTypeValue =
| "exporter"
| "freight_forwarder"
| "dj_freight_forwarder"
| "transporter";
| "transporter"
| "transit_agent";
export type CompanyNationality = "ethiopian" | "foreign";
@@ -69,11 +70,24 @@ export interface CompanyResponse {
email: string | null;
website: string | null;
attributes: Record<string, any> | null;
/**
* The transit-agent roster entry this company registered itself as when it
* took the freight-forwarder role — the two are the same business. Null for
* importers/exporters and for forwarders linked before the field existed.
*/
transitAgentId?: string | null;
transitAgent?: ForwarderTransitAgentOption | null;
companyProfiles?: CompanyProfileResponse[];
createdAt: string;
updatedAt: string;
}
/** One entry of the Ethiopian transit-agent roster, as offered to a forwarder. */
export interface ForwarderTransitAgentOption {
id: string;
name: string;
}
export interface CompanyProfileResponse {
id: string;
type: string;
@@ -149,8 +163,6 @@ export interface TransitAgentInfoResponse {
email: string | null;
phoneNumber: string | null;
isActive: boolean;
validFrom: string;
validTo: string;
company: null;
profile: null;
review: null;
@@ -369,7 +381,11 @@ export const companiesService = {
},
addCompanyProfiles: async (payload: {
profiles: { type: string; licenceNumber?: string }[];
profiles: {
type: string;
licenceNumber?: string;
transitAgentId?: string;
}[];
}): Promise<CompanyProfileResponse[]> => {
const response = await client.post<ApiResponse<CompanyProfileResponse[]>>(
URL_CONSTANTS.COMPANIES_API.COMPANY_PROFILES,
@@ -383,6 +399,7 @@ export const companiesService = {
type: ProfileTypeValue;
businessLicense?: string;
licenceNumber?: string;
transitAgentId?: string;
}): Promise<CompanyProfileResponse> => {
const response = await client.post<ApiResponse<CompanyProfileResponse>>(
URL_CONSTANTS.COMPANIES_API.COMPANY_PROFILE,
@@ -421,6 +438,7 @@ export const companiesService = {
nationality?: CompanyNationality;
cooperative?: boolean;
investorLicence?: boolean;
transitAgentId?: string;
}): Promise<CompanyInfoResponse> => {
const response = await client.post<ApiResponse<CompanyInfoResponse>>(
URL_CONSTANTS.COMPANIES_API.ONBOARDING_START,
@@ -429,6 +447,20 @@ export const companiesService = {
return unwrap(response.data);
},
/**
* The Ethiopian transit agents a freight forwarder may register itself as.
* A company that is not listed has to ask support to be added — there is no
* self-service path, since the roster is what GL assigns work from.
*/
listForwarderTransitAgents: async (): Promise<
ForwarderTransitAgentOption[]
> => {
const response = await client.get<
ApiResponse<ForwarderTransitAgentOption[]>
>(URL_CONSTANTS.TRANSIT_AGENTS_API.FORWARDER_OPTIONS);
return unwrap(response.data);
},
/**
* Give up the foreign investment-licence route and go back through eTrade.
* The API clears the typed registration and reopens onboarding at the company