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

@@ -166,14 +166,14 @@ export function TransitAssigneePanel({
) : null}
<Select
label="Transit officer"
description="Active, currently-valid transit agents only — configure the roster in Transit Agents settings"
description="Active transit agents only — configure the roster in Transit Agents settings"
placeholder={loadingAgents ? "Loading…" : "Select transit officer"}
data={agentOptions}
value={transitAgentId}
onChange={setTransitAgentId}
searchable
disabled={readOnly || loadingAgents}
nothingFoundMessage="No active, valid transit agents — add one in Transit Agents settings"
nothingFoundMessage="No active transit agents — add one in Transit Agents settings"
/>
<Group justify="flex-end" gap="sm">
{changing ? (

View File

@@ -56,6 +56,7 @@ const PROFILE_TYPE_COLOR: Record<ProfileType, string> = {
freight_forwarder: "blue",
dj_freight_forwarder: "indigo",
transporter: "grape",
transit_agent: "lime",
};
export function CompanyStatusBadge({ status }: { status: CompanyStatus }) {

View File

@@ -80,24 +80,12 @@ export const formatCell = (
);
}
if (format === "validityBadge") {
const status = String(value);
const label =
status === "VALID"
? "Valid"
: status === "EXPIRED"
? "Expired"
: "Not started";
const color =
status === "VALID"
? "edr-green"
: status === "EXPIRED"
? "red"
: "yellow";
if (format === "country") {
const code = String(value ?? "");
return (
<Badge color={color} variant="filled" size="sm" radius="md">
{label}
</Badge>
<span>
{code === "ET" ? "Ethiopia" : code === "DJ" ? "Djibouti" : code}
</span>
);
}

View File

@@ -954,6 +954,22 @@ export default function CustomerDetailPage() {
<InfoField label="Email" value={company.email} />
<InfoField label="Phone" value={company.phone} />
<InfoField label="Website" value={company.website} />
{/* Which roster entry the company is — shown only for
a transit agent or forwarder, since nobody else
has one. Unlinked = onboarded before the link. */}
{company.companyProfiles?.some(
(p) =>
p.type === "freight_forwarder" ||
p.type === "transit_agent",
) && (
<InfoField
label="Transit agent"
value={
company.transitAgent?.name ??
"Not linked to a transit agent"
}
/>
)}
<InfoField
label="Submitted on"
value={formatDate(company.createdAt)}

View File

@@ -123,6 +123,7 @@ const CUSTOMER_FILTER_DEFS: FilterDef[] = [
"freight_forwarder",
"dj_freight_forwarder",
"transporter",
"transit_agent",
] as const
).map((value) => ({ value, label: humanize(value) })),
},

View File

@@ -613,7 +613,10 @@ const RuleEngineResourcePage = () => {
</Button>
</Tooltip>
) : null}
{config.slug === "transit-agents" ? (
{/* No invite for an Ethiopian agent: it is a freight forwarder
and registers itself on the portal — the API refuses it too. */}
{config.slug === "transit-agents" &&
(row.original as unknown as TransitAgent).country !== "ET" ? (
<TransitAgentAccountAction
agent={row.original as unknown as TransitAgent}
disabled={!canUpdateControls}

View File

@@ -10,8 +10,8 @@ export type ColumnFormat =
| "boolean"
| "activeBadge"
| "rateStatus"
| "validityBadge"
| "accountBadge"
| "country"
| "date"
| "number"
| "currency"
@@ -667,21 +667,14 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
label: "Transit Agents",
category: "configuration",
subtitle:
"Djibouti transit officers GL Djibouti may assign to a shipment — each carries a validity window",
"Transit officers GL Djibouti may assign to a shipment, and the Ethiopian roster a freight forwarder registers itself against",
searchPlaceholder: "Search transit agents by name...",
cardTitleKey: "name",
columns: [
{ id: "name", header: "Name", accessorKey: "name" },
{ id: "country", header: "Country", accessorKey: "country", format: "country" },
{ id: "email", header: "Email", accessorKey: "email" },
{ id: "phoneNumber", header: "Phone", accessorKey: "phoneNumber" },
{ id: "validFrom", header: "Valid from", accessorKey: "validFrom", format: "date" },
{ id: "validTo", header: "Valid to", accessorKey: "validTo", format: "date" },
{
id: "validityStatus",
header: "Validity",
accessorKey: "validityStatus",
format: "validityBadge",
},
{
id: "hasAccount",
header: "Portal account",
@@ -692,19 +685,27 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
],
formFields: [
{ name: "name", label: "Name", type: "text", required: true },
{ name: "validFrom", label: "Valid from", type: "date", required: true },
{
name: "validTo",
label: "Valid to",
type: "date",
name: "country",
label: "Country",
type: "select",
required: true,
description: "Expired or not-yet-started agents can't be assigned — extend the dates or add a new one",
options: [
{ label: "Djibouti", value: "DJ" },
{ label: "Ethiopia", value: "ET" },
],
description:
"Ethiopian agents are also the list a customer picks itself from when registering as a freight forwarder — the two are the same business. They carry no email, phone or portal account here: the forwarder registers with its own on the portal.",
},
// Hidden for an Ethiopian agent: it is a freight forwarder and signs up on
// the portal with its own email and phone. An account minted here would
// claim that email first and its own registration would then be refused.
{
name: "email",
label: "Email",
type: "email",
optional: true,
hideWhen: { field: "country", equals: ["ET"] },
description:
"Filling this on create makes the portal account and emails the activation link. On an existing agent, use the Invite button instead — editing here only corrects the address.",
},
@@ -713,9 +714,10 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
label: "Phone number",
type: "phone",
optional: true,
description: "Ethiopian and Djiboutian mobiles also receive the link by SMS",
hideWhen: { field: "country", equals: ["ET"] },
description: "Djiboutian mobiles also receive the link by SMS",
},
{ name: "isActive", label: "Active", type: "boolean", description: "Off suspends the officer regardless of the validity window" },
{ name: "isActive", label: "Active", type: "boolean", description: "Off suspends the officer — it disappears from the assignment and forwarder lists" },
],
},
{

View File

@@ -5,8 +5,7 @@ import type { ResetChannel } from "../types/shippingLineCompany";
export interface TransitAgent {
id: string;
name: string;
validFrom: string;
validTo: string;
country: "ET" | "DJ";
isActive: boolean;
/** Null on every agent that exists only as a GL-assignable roster entry. */
email?: string | null;
@@ -29,7 +28,7 @@ export interface ActivationSendResult {
}
export const transitAgentsService = {
/** Active + currently inside its validity window — the assignment dropdown. */
/** Every active agent — the assignment dropdown. */
async listAssignable() {
const response = await apiClient.get<TransitAgent[]>(
URL_CONSTANTS.RULE_ENGINE.TRANSIT_AGENTS_ASSIGNABLE,

View File

@@ -32,7 +32,8 @@ export type ProfileType =
| "exporter"
| "freight_forwarder"
| "dj_freight_forwarder"
| "transporter";
| "transporter"
| "transit_agent";
/** Mirrors backend `ProfileStatus`. */
export type ProfileStatus =
@@ -280,6 +281,13 @@ export interface Company {
* them against the investment licence on the Documents tab.
*/
investorLicence?: boolean;
/**
* The transit-agent roster entry a freight forwarder registered itself as —
* the two are one business. Null for importers/exporters and for forwarders
* linked before the field existed.
*/
transitAgentId?: string | null;
transitAgent?: { id: string; name: string } | null;
address?: string | null;
phone?: string | null;
email?: string | null;