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

@@ -0,0 +1,75 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Links a freight-forwarder company to the transit agent it is registered as.
*
* An Ethiopian transit agent and a freight forwarder are the same business seen
* from two sides: the roster GL uses to assign an officer, and the customer
* that signs contracts on other companies' behalf. Until now nothing tied the
* two rows together, so a forwarder could onboard under any name and staff had
* no way to tell which roster entry it was.
*
* Two changes:
* - `transit_agents.country` — the roster was Djibouti-only, so every existing
* row defaults to `DJ`. Only `ET` agents are offered to a forwarder onboarding.
* - `companies.transit_agent_id` — nullable: importers and exporters have no
* agent, and pre-existing forwarders stay unlinked until they are edited.
*/
export class CompanyTransitAgentLink3930000000000 implements MigrationInterface {
name = 'CompanyTransitAgentLink3930000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.transit_agents
ADD COLUMN IF NOT EXISTS country varchar(2) NOT NULL DEFAULT 'DJ'
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "IDX_transit_agents_country"
ON freight.transit_agents (country)
`);
await queryRunner.query(`
ALTER TABLE freight.companies
ADD COLUMN IF NOT EXISTS transit_agent_id uuid
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "IDX_companies_transit_agent_id"
ON freight.companies (transit_agent_id)
`);
await queryRunner.query(`
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'FK_companies_transit_agent'
) THEN
ALTER TABLE freight.companies
ADD CONSTRAINT "FK_companies_transit_agent"
FOREIGN KEY (transit_agent_id)
REFERENCES freight.transit_agents (id)
ON DELETE SET NULL;
END IF;
END $$;
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.companies
DROP CONSTRAINT IF EXISTS "FK_companies_transit_agent"
`);
await queryRunner.query(`
DROP INDEX IF EXISTS freight."IDX_companies_transit_agent_id"
`);
await queryRunner.query(`
ALTER TABLE freight.companies
DROP COLUMN IF EXISTS transit_agent_id
`);
await queryRunner.query(`
DROP INDEX IF EXISTS freight."IDX_transit_agents_country"
`);
await queryRunner.query(`
ALTER TABLE freight.transit_agents
DROP COLUMN IF EXISTS country
`);
}
}

View File

@@ -0,0 +1,33 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Drops the transit-agent validity window.
*
* `valid_from` / `valid_to` gated which officers GL could assign, on top of the
* `is_active` switch. In practice the dates were never maintained — an agent
* whose window lapsed was simply suspended — and with Ethiopian agents now
* doubling as the roster a freight forwarder registers itself against, a
* date range that silently hides a live business is worse than no range.
* `is_active` is the single switch from here on.
*
* `down()` re-adds the columns as nullable: the dates themselves are gone.
*/
export class DropTransitAgentValidity3940000000000 implements MigrationInterface {
name = 'DropTransitAgentValidity3940000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.transit_agents
DROP COLUMN IF EXISTS valid_from,
DROP COLUMN IF EXISTS valid_to
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.transit_agents
ADD COLUMN IF NOT EXISTS valid_from date,
ADD COLUMN IF NOT EXISTS valid_to date
`);
}
}

View File

@@ -0,0 +1,29 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* Reference sequence for the new `transit_agent` operational profile.
*
* `company_profiles.type` is a plain varchar, so the role itself needs no DDL;
* what it needs is its own reference series (`TA-A00001`, …), minted on
* approval exactly like IM/EX/FF. Mirrors the baseline's sequences.
*/
export class TransitAgentProfileSequence3950000000000 implements MigrationInterface {
name = "TransitAgentProfileSequence3950000000000";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE SEQUENCE IF NOT EXISTS freight.seq_company_profile_ta
START WITH 1
INCREMENT BY 1
NO MINVALUE
NO MAXVALUE
CACHE 1
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`DROP SEQUENCE IF EXISTS freight.seq_company_profile_ta`,
);
}
}

View File

@@ -656,4 +656,67 @@ export class BookingLifecycleNotifierService {
}, },
); );
} }
/**
* The customer picked a registered transit agent (a freight forwarder on the
* platform) to clear this booking. Tell the forwarder company — every one of
* its portal users in the bell, plus SMS and email to its contact — so the
* job shows up in its Assigned Bookings tab and it can start preparing
* documents. The company is found through its transit-agent link; an agent
* nobody has registered against gets no message, since there is nobody to
* send it to. Never throws: a failed notice must not undo a completed booking.
*/
async transitAgentAssigned(
b: Booking,
agent: { id: string; name: string },
): Promise<void> {
try {
const [forwarder]: Array<{ id: string }> = await this.dataSource.query(
`SELECT id FROM freight.companies
WHERE transit_agent_id = $1 AND deleted_at IS NULL
LIMIT 1`,
[agent.id],
);
if (!forwarder) {
this.logger.warn(
`Transit agent ${agent.name} has no forwarder company — assignment notice for ${this.ref(b)} not sent`,
);
return;
}
const title = 'New booking assigned to you';
const body = `Booking ${b.reference} has been assigned to ${agent.name} for customs clearance. Open Assigned Bookings in the portal to see it.`;
void this.inbox.notify({
recipients: { companyId: forwarder.id },
audience: NotificationAudience.PORTAL,
type: NotificationType.BOOKING_STATUS,
title,
body,
link: '/forwarder/assigned-bookings',
data: { bookingId: b.id, reference: b.reference, transitAgentId: agent.id },
});
const { phone, email } = await resolveCompanyNotifyContact(
this.dataSource,
forwarder.id,
);
const message = `EDR Freight: ${body}`;
if (phone) {
try {
await this.notifications.directSend('sms', phone, message);
} catch (err) {
this.logger.warn(`Forwarder SMS failed for ${this.ref(b)}: ${(err as Error).message}`);
}
}
if (email) {
try {
await this.notifications.directSend('email', email, message);
} catch (err) {
this.logger.warn(`Forwarder email failed for ${this.ref(b)}: ${(err as Error).message}`);
}
}
} catch (err) {
this.logger.warn(
`transitAgentAssigned failed for ${this.ref(b)}: ${(err as Error).message}`,
);
}
}
} }

View File

@@ -330,6 +330,7 @@ export class CompaniesController {
dto.nationality, dto.nationality,
dto.cooperative, dto.cooperative,
dto.investorLicence, dto.investorLicence,
dto.transitAgentId,
); );
return new CompanyInfoResponseDto(profile, company); return new CompanyInfoResponseDto(profile, company);
} }
@@ -363,6 +364,7 @@ export class CompaniesController {
dto.type, dto.type,
dto.businessLicense, dto.businessLicense,
dto.licenceNumber, dto.licenceNumber,
dto.transitAgentId,
); );
return new ResponseCompanyProfileDto(profile); return new ResponseCompanyProfileDto(profile);
} }

View File

@@ -80,6 +80,9 @@ function makeService(overrides: Partial<Ctx> = {}) {
const company = () => ({ const company = () => ({
id: "company-1", id: "company-1",
// Already named its roster entry: taking the forwarder role needs one,
// and these tests are about the PoA gate, not the transit-agent link.
transitAgentId: "ta-et",
status: ctx.status, status: ctx.status,
nationality: ctx.nationality, nationality: ctx.nationality,
attributes: ctx.attributes, attributes: ctx.attributes,
@@ -175,6 +178,14 @@ function makeService(overrides: Partial<Ctx> = {}) {
deps.companyNotifier as never, deps.companyNotifier as never,
{} as never, {} as never,
deps.verifayda as never, deps.verifayda as never,
{
findById: jest.fn(async () => ({
id: "ta-et",
name: "Abyssinia Transit",
isActive: true,
country: "ET",
})),
} as never, // transitAgentsRepo
); );
jest jest

View File

@@ -66,6 +66,7 @@ function makeService(company: Record<string, unknown> | null) {
{} as never, {} as never,
{} as never, {} as never,
{} as never, {} as never,
{} as never,
); );
jest jest

View File

@@ -101,6 +101,7 @@ function makeService(
{ changeRequestSubmitted: jest.fn() } as never, { changeRequestSubmitted: jest.fn() } as never,
{} as never, {} as never,
{} as never, {} as never,
{} as never,
); );
jest jest

View File

@@ -46,6 +46,9 @@ function makeService(overrides: Partial<Ctx> = {}) {
const company = () => ({ const company = () => ({
id: "company-1", id: "company-1",
// Already named its roster entry: taking the forwarder role needs one,
// and these tests are about the PoA gate, not the transit-agent link.
transitAgentId: "ta-et",
status: ctx.status, status: ctx.status,
attributes: ctx.attributes, attributes: ctx.attributes,
companyProfiles: ctx.profileTypes.map((type, i) => ({ companyProfiles: ctx.profileTypes.map((type, i) => ({
@@ -146,6 +149,14 @@ function makeService(overrides: Partial<Ctx> = {}) {
deps.companyNotifier as never, deps.companyNotifier as never,
{} as never, {} as never,
{} as never, {} as never,
{
findById: jest.fn(async () => ({
id: "ta-et",
name: "Abyssinia Transit",
isActive: true,
country: "ET",
})),
} as never, // transitAgentsRepo
); );
// getCompanyInfoByUserId does its own lookups; the stubs above are enough for // getCompanyInfoByUserId does its own lookups; the stubs above are enough for

View File

@@ -75,6 +75,7 @@ function makeService(status: ProfileStatus) {
companyNotifier as never, companyNotifier as never,
dataSource as never, dataSource as never,
{} as never, {} as never,
{} as never,
); );
return { service, profile, written, companyProfilesRepo }; return { service, profile, written, companyProfilesRepo };

View File

@@ -70,6 +70,7 @@ function makeService(attributes: Record<string, unknown> = {}) {
{} as never, {} as never,
{} as never, {} as never,
{} as never, {} as never,
{} as never,
); );
jest jest

View File

@@ -54,6 +54,7 @@ function makeService(existing: ExistingProfile[]) {
{} as never, {} as never,
{} as never, {} as never,
{} as never, {} as never,
{} as never,
); );
jest jest

View File

@@ -31,6 +31,31 @@ import {
POA_DELEGATION_PENDING_CODE, POA_DELEGATION_PENDING_CODE,
} from "../file-upload-settings/poa-delegation.constants"; } from "../file-upload-settings/poa-delegation.constants";
import { VerifaydaService } from "../verifayda/verifayda.service"; import { VerifaydaService } from "../verifayda/verifayda.service";
import {
TransitAgent,
TransitAgentCountry,
} from "../transit-agents/entities/transit-agent.entity";
import { TransitAgentsRepository } from "../transit-agents/transit-agents.repository";
/**
* The roles that ARE an Ethiopian transit-agent roster entry. Both name the
* company's `transitAgentId`; the difference is what else the company does —
* a forwarder also trades on other companies' behalf, a plain transit agent
* only clears customs for bookings customers assign to it.
*/
function isAgentRole(type: ProfileType): boolean {
// A function rather than a module-level array: the entity module is still
// initialising when this file loads (company-profile → company → …), so
// reading the enum at load time throws.
return (
type === ProfileType.freightForwarder || type === ProfileType.transitAgent
);
}
/** True when the company does nothing but act as a transit agent. */
function isTransitAgentOnly(types: ProfileType[]): boolean {
return types.length > 0 && types.every((t) => t === ProfileType.transitAgent);
}
import { import {
buildCompanyIdentityState, buildCompanyIdentityState,
CompanyIdentityStateDto, CompanyIdentityStateDto,
@@ -204,6 +229,7 @@ export class CompaniesService {
private readonly companyNotifier: CompanyNotifierService, private readonly companyNotifier: CompanyNotifierService,
private readonly dataSource: DataSource, private readonly dataSource: DataSource,
private readonly verifaydaService: VerifaydaService, private readonly verifaydaService: VerifaydaService,
private readonly transitAgentsRepo: TransitAgentsRepository,
) { } ) { }
/** /**
@@ -387,21 +413,21 @@ export class CompaniesService {
nationality?: CompanyNationality, nationality?: CompanyNationality,
cooperative?: boolean, cooperative?: boolean,
investorLicence?: boolean, investorLicence?: boolean,
transitAgentId?: string,
): Promise<{ profile: ExternalProfile; company: Company }> { ): Promise<{ profile: ExternalProfile; company: Company }> {
const needsAgent = roles.some(isAgentRole);
// A company that is ONLY a transit agent has nothing else to tell us: its
// registration IS the roster entry it picked, so onboarding ends here.
const transitAgentOnly = isTransitAgentOnly(roles);
// Already started — reuse the existing draft, just ensure roles exist and // Already started — reuse the existing draft, just ensure roles exist and
// keep the nationality up to date if it was (re)selected. // keep the nationality up to date if it was (re)selected.
const existing = await this.profilesRepo.findByUserId(identity.userId); const existing = await this.profilesRepo.findByUserId(identity.userId);
if (existing) { if (existing) {
const companyId = existing.company?.id ?? existing.companyId; const companyId = existing.company?.id ?? existing.companyId;
// Only load the row when the answer actually depends on it: to merge the // The stored row decides more than one answer here: the flags the caller
// flag into `attributes`, or to read a stored one the caller didn't send. // didn't send, the transit agent a forwarder already linked, and whether
const needsCompany = // dropping the forwarder role has a link to clear.
cooperative !== undefined || const current = await this.companiesRepo.findById(companyId);
investorLicence !== undefined ||
roles.includes(ProfileType.freightForwarder);
const current = needsCompany
? await this.companiesRepo.findById(companyId)
: null;
const isCoop = cooperative ?? isCooperative(current); const isCoop = cooperative ?? isCooperative(current);
const isInvestor = investorLicence ?? hasInvestorLicence(current); const isInvestor = investorLicence ?? hasInvestorLicence(current);
this.assertRolesAllowedForCooperative(isCoop, roles); this.assertRolesAllowedForCooperative(isCoop, roles);
@@ -411,8 +437,38 @@ export class CompaniesService {
isCoop, isCoop,
nationality ?? current?.nationality ?? undefined, nationality ?? current?.nationality ?? undefined,
); );
// A re-run that keeps an agent role may omit the agent it already
// picked; one that drops both agent roles drops the link with it, so a
// company that later re-adds one is asked again rather than inheriting a
// stale answer.
const linkedAgent = needsAgent
? await this.resolveLinkedTransitAgent(
transitAgentId ?? current?.transitAgentId ?? undefined,
)
: null;
const before = await this.companyProfilesRepo.findByCompanyId(companyId);
const wasTransitAgentOnly = isTransitAgentOnly(before.map((p) => p.type));
await this.syncCompanyProfiles(companyId, companyType, roles); await this.syncCompanyProfiles(companyId, companyType, roles);
const updates: Partial<Company> = {}; const updates: Partial<Company> = {};
if ((linkedAgent?.id ?? null) !== (current?.transitAgentId ?? null)) {
updates.transitAgentId = linkedAgent?.id ?? null;
}
const profilePatch: Partial<ExternalProfile> = {};
if (transitAgentOnly && linkedAgent) {
// The company is the agent — it gets the roster's name, and there is
// no company/owner/documents step left to take.
updates.name = linkedAgent.name;
if (!existing.onboardingCompleted) {
profilePatch.onboardingCompleted = true;
profilePatch.onboardingStep = "transit-agent";
}
} else if (existing.onboardingCompleted && wasTransitAgentOnly) {
// Adding a licensed role to a transit-agent-only company reopens the
// wizard at the company step: importing or forwarding needs the TIN,
// owner, contact and documents the transit agent never had to give.
profilePatch.onboardingCompleted = false;
profilePatch.onboardingStep = "company";
}
if (nationality) updates.nationality = nationality; if (nationality) updates.nationality = nationality;
// Ticking the box on a draft that was saved as foreign has to correct the // Ticking the box on a draft that was saved as foreign has to correct the
// stored nationality too, or the company keeps resolving to the foreign // stored nationality too, or the company keeps resolving to the foreign
@@ -446,10 +502,9 @@ export class CompaniesService {
if (Object.keys(updates).length > 0) { if (Object.keys(updates).length > 0) {
await this.companiesRepo.update(companyId, updates); await this.companiesRepo.update(companyId, updates);
} }
if (backToEtrade) { if (backToEtrade) profilePatch.onboardingStep = "company";
await this.profilesRepo.update(existing.id, { if (Object.keys(profilePatch).length > 0) {
onboardingStep: "company", await this.profilesRepo.update(existing.id, profilePatch);
});
} }
return this.getCompanyInfoByUserId(identity.userId); return this.getCompanyInfoByUserId(identity.userId);
} }
@@ -463,16 +518,23 @@ export class CompaniesService {
); );
const allowedTypes = this.getProfileTypeForCompanyType(companyType); const allowedTypes = this.getProfileTypeForCompanyType(companyType);
const chosenTypes = roles.filter((t) => allowedTypes.includes(t)); const chosenTypes = roles.filter((t) => allowedTypes.includes(t));
const linkedAgent = needsAgent
? await this.resolveLinkedTransitAgent(transitAgentId)
: null;
const company = await this.companiesRepo.create({ const company = await this.companiesRepo.create({
name: identity.firstName name:
? `${identity.firstName}'s company` transitAgentOnly && linkedAgent
: "New company", ? linkedAgent.name
: identity.firstName
? `${identity.firstName}'s company`
: "New company",
type: companyType, type: companyType,
tin: await this.generateDraftTin(), tin: await this.generateDraftTin(),
country: "Ethiopia", country: "Ethiopia",
nationality: nationality ?? CompanyNationality.Ethiopian, nationality: nationality ?? CompanyNationality.Ethiopian,
status: CompanyStatus.Pending, status: CompanyStatus.Pending,
transitAgentId: linkedAgent?.id ?? null,
...(cooperative || investorLicence ...(cooperative || investorLicence
? { ? {
attributes: { attributes: {
@@ -489,8 +551,10 @@ export class CompaniesService {
firstName: identity.firstName, firstName: identity.firstName,
lastName: identity.lastName, lastName: identity.lastName,
isPrimaryContact: true, isPrimaryContact: true,
onboardingStep: "company", // A transit-agent-only company is done the moment it picks its roster
onboardingCompleted: false, // entry — see `transitAgentOnly` above.
onboardingStep: transitAgentOnly ? "transit-agent" : "company",
onboardingCompleted: transitAgentOnly,
}); });
await this.syncCompanyProfiles(company.id, companyType, chosenTypes); await this.syncCompanyProfiles(company.id, companyType, chosenTypes);
@@ -498,6 +562,75 @@ export class CompaniesService {
return this.getCompanyInfoByUserId(identity.userId); return this.getCompanyInfoByUserId(identity.userId);
} }
/**
* The transit agent a freight forwarder registers itself as.
*
* A forwarder and an Ethiopian transit agent are the same business, so the
* role cannot be taken without naming which roster entry it is: a company
* that is not on the roster asks support to be added first, which is what
* the portal's "didn't find my company" note says. Foreign and suspended
* entries are refused for the same reason a missing one is — none of them
* is a forwarder EDR will assign work to.
*/
private async resolveLinkedTransitAgent(
transitAgentId: string | undefined,
): Promise<TransitAgent> {
if (!transitAgentId) {
throw new BadRequestException(
"Select your company from the transit agent list to register as a transit agent or freight forwarder. If it is not listed, contact support to be added.",
);
}
const agent = await this.transitAgentsRepo.findById(transitAgentId);
if (
!agent ||
!agent.isActive ||
agent.country !== TransitAgentCountry.Ethiopia
) {
throw new BadRequestException(
"The selected transit agent is not an active Ethiopian transit agent. Pick another one or contact support.",
);
}
return agent;
}
/**
* Make `agent` the company's transit agent if it is not already. Shared by
* every add-role path: the link is per company, so a forwarder that already
* picked its roster entry is not asked again when it adds the transit agent
* role, and vice versa.
*/
private async linkTransitAgent(
company: Company,
transitAgentId: string | undefined,
): Promise<void> {
const agent = await this.resolveLinkedTransitAgent(
transitAgentId ?? company.transitAgentId ?? undefined,
);
if (company.transitAgentId === agent.id) return;
await this.companiesRepo.update(company.id, { transitAgentId: agent.id });
company.transitAgentId = agent.id;
}
/**
* A transit-agent-only company that takes on a licensed role has to go
* back through the wizard: importing, exporting or forwarding needs the TIN,
* owner, contact and documents the transit agent never had to give. The
* portal reopens the wizard at the company step the moment this flips.
*/
private async reopenOnboardingForLicensedRole(
profile: ExternalProfile,
profilesBefore: CompanyProfile[],
addedTypes: ProfileType[],
): Promise<void> {
if (!profile.onboardingCompleted) return;
if (!isTransitAgentOnly(profilesBefore.map((p) => p.type))) return;
if (!addedTypes.some((t) => t !== ProfileType.transitAgent)) return;
await this.profilesRepo.update(profile.id, {
onboardingCompleted: false,
onboardingStep: "company",
});
}
/** /**
* A co-operative union or farm cannot hold the freight-forwarder role. * A co-operative union or farm cannot hold the freight-forwarder role.
* *
@@ -517,6 +650,11 @@ export class CompaniesService {
"A co-operative union or farm cannot register as a freight forwarder — that role requires a business licence.", "A co-operative union or farm cannot register as a freight forwarder — that role requires a business licence.",
); );
} }
if (roles.includes(ProfileType.transitAgent)) {
throw new BadRequestException(
"A co-operative union or farm cannot register as a transit agent — that is licensed customs work.",
);
}
} }
/** /**
@@ -675,6 +813,7 @@ export class CompaniesService {
// External profiles carry the onboarding flag the backoffice gates // External profiles carry the onboarding flag the backoffice gates
// approval decisions on (see ResponseCompanyDto.onboardingCompleted). // approval decisions on (see ResponseCompanyDto.onboardingCompleted).
company.profiles = await this.profilesRepo.findByCompanyId(id); company.profiles = await this.profilesRepo.findByCompanyId(id);
await this.attachTransitAgent(company);
return company; return company;
} }
@@ -715,10 +854,22 @@ export class CompaniesService {
company.companyProfiles = await this.companyProfilesRepo.findByCompanyId( company.companyProfiles = await this.companyProfilesRepo.findByCompanyId(
company.id, company.id,
); );
await this.attachTransitAgent(company);
return { profile, company }; return { profile, company };
} }
/**
* Hang the linked transit agent off the company so responses can name it.
* A separate read rather than a relation join: `findById` on the repository
* loads no relations, and every other caller of it has no use for the agent.
*/
private async attachTransitAgent(company: Company): Promise<void> {
company.transitAgent = company.transitAgentId
? await this.transitAgentsRepo.findById(company.transitAgentId)
: null;
}
/** /**
* Dashboard KPIs for the portal home (MyPortalPage), aggregated from the * Dashboard KPIs for the portal home (MyPortalPage), aggregated from the
* current user's company bookings. All figures are scoped to that company. * current user's company bookings. All figures are scoped to that company.
@@ -1799,6 +1950,7 @@ export class CompaniesService {
ProfileType.importer, ProfileType.importer,
ProfileType.exporter, ProfileType.exporter,
ProfileType.freightForwarder, ProfileType.freightForwarder,
ProfileType.transitAgent,
]; ];
case "freight_forwarder": case "freight_forwarder":
return [ProfileType.freightForwarder]; return [ProfileType.freightForwarder];
@@ -2154,7 +2306,11 @@ export class CompaniesService {
*/ */
async addCompanyProfilesForUser( async addCompanyProfilesForUser(
userId: string, userId: string,
inputs: Array<{ type: ProfileType; licenceNumber?: string }>, inputs: Array<{
type: ProfileType;
licenceNumber?: string;
transitAgentId?: string;
}>,
): Promise<CompanyProfile[]> { ): Promise<CompanyProfile[]> {
const types = inputs.map((i) => i.type); const types = inputs.map((i) => i.type);
const profile = await this.profilesRepo.findByUserId(userId); const profile = await this.profilesRepo.findByUserId(userId);
@@ -2164,6 +2320,8 @@ export class CompaniesService {
const companyId = profile.company?.id ?? profile.companyId; const companyId = profile.company?.id ?? profile.companyId;
const company = await this.findCompanyById(companyId); const company = await this.findCompanyById(companyId);
const allowedTypes = this.getProfileTypeForCompanyType(company.type); const allowedTypes = this.getProfileTypeForCompanyType(company.type);
const before = company.companyProfiles ?? [];
const wasTransitAgentOnly = isTransitAgentOnly(before.map((p) => p.type));
for (const type of types) { for (const type of types) {
if (!allowedTypes.includes(type)) { if (!allowedTypes.includes(type)) {
@@ -2191,14 +2349,31 @@ export class CompaniesService {
); );
} }
// A transit agent or forwarder IS a roster entry — name it, once per
// company, before the role exists.
if (isAgentRole(type)) {
this.assertRolesAllowedForCooperative(isCooperative(company), [type]);
await this.linkTransitAgent(
company,
inputs.find((i) => i.type === type)?.transitAgentId,
);
}
// Which eTrade business this role operates as. Resolved (and rejected if // Which eTrade business this role operates as. Resolved (and rejected if
// absent) BEFORE the row is created, so a role never lands unattached on // absent) BEFORE the row is created, so a role never lands unattached on
// a company that has licences to pick from. // a company that has licences to pick from. A transit agent has none —
const etradeBusiness = await this.resolveProfileBusiness( // and a transit-agent-only company has no eTrade record to pick from
company, // yet: its first licensed role attaches the business on the wizard's
inputs.find((i) => i.type === type)?.licenceNumber, // licence step, once the TIN has been looked up, exactly like a role
type, // picked at onboarding.
); const etradeBusiness =
type === ProfileType.transitAgent || wasTransitAgentOnly
? null
: await this.resolveProfileBusiness(
company,
inputs.find((i) => i.type === type)?.licenceNumber,
type,
);
// Self-service role adds start Pending and carry no reference — a reference // Self-service role adds start Pending and carry no reference — a reference
// is minted only when a backoffice reviewer approves the role. // is minted only when a backoffice reviewer approves the role.
@@ -2210,6 +2385,7 @@ export class CompaniesService {
}); });
} }
await this.reopenOnboardingForLicensedRole(profile, before, types);
return this.companyProfilesRepo.findByCompanyId(companyId); return this.companyProfilesRepo.findByCompanyId(companyId);
} }
@@ -2224,6 +2400,7 @@ export class CompaniesService {
type: ProfileType, type: ProfileType,
businessLicense?: string, businessLicense?: string,
licenceNumber?: string, licenceNumber?: string,
transitAgentId?: string,
): Promise<CompanyProfile> { ): Promise<CompanyProfile> {
const profile = await this.profilesRepo.findByUserId(userId); const profile = await this.profilesRepo.findByUserId(userId);
if (!profile) if (!profile)
@@ -2248,12 +2425,18 @@ export class CompaniesService {
await this.effectivePoaAttributes(company), await this.effectivePoaAttributes(company),
); );
} }
if (!created && isAgentRole(type)) {
this.assertRolesAllowedForCooperative(isCooperative(company), [type]);
await this.linkTransitAgent(company, transitAgentId);
}
if (!created) { if (!created) {
const etradeBusiness = await this.resolveProfileBusiness( // See addCompanyProfilesForUser: no business for a transit agent, nor
company, // for a transit-agent-only company's first licensed role.
licenceNumber, const etradeBusiness =
type, type === ProfileType.transitAgent ||
); isTransitAgentOnly((company.companyProfiles ?? []).map((p) => p.type))
? null
: await this.resolveProfileBusiness(company, licenceNumber, type);
// New self-service roles start Pending (awaiting backoffice approval) and // New self-service roles start Pending (awaiting backoffice approval) and
// carry no reference until approved. // carry no reference until approved.
created = await this.companyProfilesRepo.create({ created = await this.companyProfilesRepo.create({
@@ -2263,6 +2446,11 @@ export class CompaniesService {
etradeBusiness, etradeBusiness,
status: ProfileStatus.Pending, status: ProfileStatus.Pending,
}); });
await this.reopenOnboardingForLicensedRole(
profile,
company.companyProfiles ?? [],
[type],
);
} }
return created; return created;
@@ -2331,9 +2519,13 @@ export class CompaniesService {
})); }));
const missingDocs = documents.filter((d) => d.isRequired && !d.uploaded); const missingDocs = documents.filter((d) => d.isRequired && !d.uploaded);
// 3. Per-operational-profile business licenses (FileRecord-backed). // 3. Per-operational-profile business licenses (FileRecord-backed). A
// transit agent holds none here — its roster entry is its registration —
// so it owes neither a licence nor an eTrade business.
const licenseProfiles = await Promise.all( const licenseProfiles = await Promise.all(
(company.companyProfiles ?? []).map(async (p) => { (company.companyProfiles ?? [])
.filter((p) => p.type !== ProfileType.transitAgent)
.map(async (p) => {
const records = await this.filesService.findByResource( const records = await this.filesService.findByResource(
p.id, p.id,
LICENSE_RESOURCE, LICENSE_RESOURCE,
@@ -3775,7 +3967,11 @@ export class CompaniesService {
companyId: string, companyId: string,
tradeDirection: string, tradeDirection: string,
): Promise<string | null> { ): Promise<string | null> {
const profiles = await this.companyProfilesRepo.findByCompanyId(companyId); // A transit agent profile never carries a booking — it is the roster
// side of the company, not a trade role.
const profiles = (
await this.companyProfilesRepo.findByCompanyId(companyId)
).filter((p) => p.type !== ProfileType.transitAgent);
if (profiles.length === 0) return null; if (profiles.length === 0) return null;
const naturalType = const naturalType =

View File

@@ -0,0 +1,343 @@
import { BadRequestException } from "@nestjs/common";
import { TransitAgentCountry } from "../transit-agents/entities/transit-agent.entity";
import { CompaniesService } from "./companies.service";
import { CompanyType } from "./entities/company.entity";
import { ProfileStatus, ProfileType } from "./entities/company-profile.entity";
/**
* A freight forwarder IS an Ethiopian transit agent, so taking the role means
* naming which roster entry the company is. These lock the rule at the door:
* no agent → refused; a foreign or suspended one → refused; and the link
* follows the role, both on a fresh draft and when a draft is re-run.
*/
interface ExistingProfile {
id: string;
type: ProfileType;
status: ProfileStatus;
}
const ETHIOPIAN = {
id: "ta-et",
name: "Abyssinia Transit",
isActive: true,
country: TransitAgentCountry.Ethiopia,
};
const DJIBOUTIAN = {
id: "ta-dj",
name: "Ahmed Bourhan",
isActive: true,
country: TransitAgentCountry.Djibouti,
};
const SUSPENDED = { ...ETHIOPIAN, id: "ta-off", isActive: false };
const AGENTS = [ETHIOPIAN, DJIBOUTIAN, SUSPENDED];
function makeService(opts: {
existing?: ExistingProfile[] | null;
linkedAgentId?: string | null;
/** The draft's owner already submitted onboarding (transit-agent-only). */
onboardingCompleted?: boolean;
}) {
const companyProfilesRepo = {
findByCompanyId: jest.fn(async () => opts.existing ?? []),
create: jest.fn(async (row: Record<string, unknown>) => ({
id: "new",
...row,
})),
softDelete: jest.fn(async () => undefined),
};
const companiesRepo = {
update: jest.fn(async () => null),
create: jest.fn(async (row: Record<string, unknown>) => ({
id: "company-1",
...row,
})),
findById: jest.fn(async () => ({
id: "company-1",
attributes: {},
transitAgentId: opts.linkedAgentId ?? null,
})),
};
const profilesRepo = {
findByUserId: jest.fn(async () =>
opts.existing === null
? null
: {
id: "external-1",
companyId: "company-1",
company: { id: "company-1" },
onboardingCompleted: opts.onboardingCompleted ?? false,
},
),
create: jest.fn(async (row: Record<string, unknown>) => ({
id: "external-1",
...row,
})),
update: jest.fn(async () => null),
};
const transitAgentsRepo = {
findById: jest.fn(async (id: string) => AGENTS.find((a) => a.id === id) ?? null),
};
const service = new CompaniesService(
companiesRepo as never,
companyProfilesRepo as never,
{} as never,
{} as never,
profilesRepo as never,
{} as never,
{} as never,
{} as never,
{} as never,
{} as never,
{} as never,
{} as never,
transitAgentsRepo as never,
);
jest
.spyOn(service, "getCompanyInfoByUserId")
.mockImplementation(
async () =>
({ profile: { id: "external-1" }, company: { id: "company-1" } }) as never,
);
// The draft TIN is random and irrelevant here.
jest
.spyOn(service as never, "generateDraftTin" as never)
.mockImplementation((async () => "D000000001") as never);
return { service, companiesRepo, companyProfilesRepo, profilesRepo };
}
const identity = { userId: "user-1", firstName: "Abebe", lastName: "K" };
const start = (
service: CompaniesService,
roles: ProfileType[],
transitAgentId?: string,
) =>
service.startOnboarding(
identity as never,
CompanyType.Customer,
roles,
undefined,
undefined,
undefined,
transitAgentId,
);
describe("a freight forwarder must name its transit agent", () => {
it("refuses the forwarder role without an agent on a fresh draft", async () => {
const { service, companiesRepo } = makeService({ existing: null });
await expect(
start(service, [ProfileType.freightForwarder]),
).rejects.toBeInstanceOf(BadRequestException);
expect(companiesRepo.create).not.toHaveBeenCalled();
});
it("refuses a Djiboutian agent", async () => {
const { service } = makeService({ existing: null });
await expect(
start(service, [ProfileType.freightForwarder], DJIBOUTIAN.id),
).rejects.toThrow(/not an active Ethiopian transit agent/);
});
it("refuses a suspended agent", async () => {
const { service } = makeService({ existing: null });
await expect(
start(service, [ProfileType.freightForwarder], SUSPENDED.id),
).rejects.toThrow(/not an active Ethiopian transit agent/);
});
it("stores the Ethiopian agent on the new draft company", async () => {
const { service, companiesRepo } = makeService({ existing: null });
await start(
service,
[ProfileType.importer, ProfileType.freightForwarder],
ETHIOPIAN.id,
);
expect(companiesRepo.create).toHaveBeenCalledWith(
expect.objectContaining({ transitAgentId: ETHIOPIAN.id }),
);
});
it("does not ask an importer for an agent, and stores none", async () => {
const { service, companiesRepo } = makeService({ existing: null });
await start(service, [ProfileType.importer], ETHIOPIAN.id);
expect(companiesRepo.create).toHaveBeenCalledWith(
expect.objectContaining({ transitAgentId: null }),
);
});
});
describe("re-running role selection keeps the link in step with the role", () => {
const importerOnly: ExistingProfile[] = [
{ id: "p-imp", type: ProfileType.importer, status: ProfileStatus.Pending },
];
it("links the agent when forwarding is added to an existing draft", async () => {
const { service, companiesRepo } = makeService({ existing: importerOnly });
await start(
service,
[ProfileType.importer, ProfileType.freightForwarder],
ETHIOPIAN.id,
);
expect(companiesRepo.update).toHaveBeenCalledWith(
"company-1",
expect.objectContaining({ transitAgentId: ETHIOPIAN.id }),
);
});
it("keeps the agent already linked when the re-run omits it", async () => {
const { service, companiesRepo } = makeService({
existing: importerOnly,
linkedAgentId: ETHIOPIAN.id,
});
await start(service, [ProfileType.importer, ProfileType.freightForwarder]);
expect(companiesRepo.update).not.toHaveBeenCalledWith(
"company-1",
expect.objectContaining({ transitAgentId: expect.anything() }),
);
});
it("still refuses a re-run that adds forwarding with nothing linked", async () => {
const { service } = makeService({ existing: importerOnly });
await expect(
start(service, [ProfileType.importer, ProfileType.freightForwarder]),
).rejects.toBeInstanceOf(BadRequestException);
});
it("clears the link when the forwarder role is dropped", async () => {
const { service, companiesRepo } = makeService({
existing: importerOnly,
linkedAgentId: ETHIOPIAN.id,
});
await start(service, [ProfileType.importer]);
expect(companiesRepo.update).toHaveBeenCalledWith(
"company-1",
expect.objectContaining({ transitAgentId: null }),
);
});
});
/**
* A company that is ONLY a transit agent has nothing else to tell us: its
* registration is the roster entry it picked, so onboarding ends right there
* — and reopens if it later takes on a licensed role.
*/
describe("a transit-agent-only company finishes onboarding on the roster pick", () => {
it("names the draft after the agent and completes onboarding at once", async () => {
const { service, companiesRepo, profilesRepo } = makeService({
existing: null,
});
await start(service, [ProfileType.transitAgent], ETHIOPIAN.id);
expect(companiesRepo.create).toHaveBeenCalledWith(
expect.objectContaining({
name: ETHIOPIAN.name,
transitAgentId: ETHIOPIAN.id,
}),
);
expect(profilesRepo.create).toHaveBeenCalledWith(
expect.objectContaining({
onboardingCompleted: true,
onboardingStep: "transit-agent",
}),
);
});
it("still refuses the role without an agent", async () => {
const { service, companiesRepo } = makeService({ existing: null });
await expect(
start(service, [ProfileType.transitAgent]),
).rejects.toBeInstanceOf(BadRequestException);
expect(companiesRepo.create).not.toHaveBeenCalled();
});
it("keeps the full wizard when a trade role is picked alongside", async () => {
const { service, companiesRepo, profilesRepo } = makeService({
existing: null,
});
await start(
service,
[ProfileType.transitAgent, ProfileType.importer],
ETHIOPIAN.id,
);
expect(companiesRepo.create).toHaveBeenCalledWith(
expect.objectContaining({ transitAgentId: ETHIOPIAN.id }),
);
expect(companiesRepo.create).not.toHaveBeenCalledWith(
expect.objectContaining({ name: ETHIOPIAN.name }),
);
expect(profilesRepo.create).toHaveBeenCalledWith(
expect.objectContaining({
onboardingCompleted: false,
onboardingStep: "company",
}),
);
});
it("completes an existing transit-agent-only draft on the pick", async () => {
const { service, companiesRepo, profilesRepo } = makeService({
existing: [
{ id: "p-ta", type: ProfileType.transitAgent, status: ProfileStatus.Pending },
],
});
await start(service, [ProfileType.transitAgent], ETHIOPIAN.id);
expect(companiesRepo.update).toHaveBeenCalledWith(
"company-1",
expect.objectContaining({
transitAgentId: ETHIOPIAN.id,
name: ETHIOPIAN.name,
}),
);
expect(profilesRepo.update).toHaveBeenCalledWith(
"external-1",
expect.objectContaining({
onboardingCompleted: true,
onboardingStep: "transit-agent",
}),
);
});
it("reopens onboarding at the company step when a licensed role is added", async () => {
const { service, profilesRepo } = makeService({
existing: [
{ id: "p-ta", type: ProfileType.transitAgent, status: ProfileStatus.Active },
],
linkedAgentId: ETHIOPIAN.id,
onboardingCompleted: true,
});
await start(service, [ProfileType.transitAgent, ProfileType.importer]);
expect(profilesRepo.update).toHaveBeenCalledWith(
"external-1",
expect.objectContaining({
onboardingCompleted: false,
onboardingStep: "company",
}),
);
});
});

View File

@@ -10,6 +10,7 @@ const SEQUENCE_MAP: Record<ProfileType, string> = {
[ProfileType.freightForwarder]: "seq_company_profile_ffe", [ProfileType.freightForwarder]: "seq_company_profile_ffe",
[ProfileType.djFreightForwarder]: "seq_company_profile_fwj", [ProfileType.djFreightForwarder]: "seq_company_profile_fwj",
[ProfileType.transporter]: "seq_company_profile_tr", [ProfileType.transporter]: "seq_company_profile_tr",
[ProfileType.transitAgent]: "seq_company_profile_ta",
}; };
const PREFIX_MAP: Record<ProfileType, string> = { const PREFIX_MAP: Record<ProfileType, string> = {
@@ -18,6 +19,7 @@ const PREFIX_MAP: Record<ProfileType, string> = {
[ProfileType.freightForwarder]: "FF", [ProfileType.freightForwarder]: "FF",
[ProfileType.djFreightForwarder]: "FWJ", [ProfileType.djFreightForwarder]: "FWJ",
[ProfileType.transporter]: "TR", [ProfileType.transporter]: "TR",
[ProfileType.transitAgent]: "TA",
}; };
const SERIES_LETTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"; const SERIES_LETTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";

View File

@@ -86,16 +86,6 @@ export class TransitAgentInfoResponseDto {
@ApiProperty() @ApiProperty()
isActive: boolean; isActive: boolean;
@ApiProperty({
description: "Start of the agent's validity window (yyyy-MM-dd)",
})
validFrom: string;
@ApiProperty({
description: "End of the agent's validity window (yyyy-MM-dd)",
})
validTo: string;
/** Always null — see {@link ShippingLineInfoResponseDto.company}. */ /** Always null — see {@link ShippingLineInfoResponseDto.company}. */
@ApiProperty({ nullable: true }) @ApiProperty({ nullable: true })
company: null = null; company: null = null;
@@ -112,8 +102,6 @@ export class TransitAgentInfoResponseDto {
this.email = entity.email ?? null; this.email = entity.email ?? null;
this.phoneNumber = entity.phoneNumber ?? null; this.phoneNumber = entity.phoneNumber ?? null;
this.isActive = entity.isActive; this.isActive = entity.isActive;
this.validFrom = entity.validFrom;
this.validTo = entity.validTo;
} }
} }

View File

@@ -5,6 +5,7 @@ import {
IsEnum, IsEnum,
IsOptional, IsOptional,
IsString, IsString,
IsUUID,
MaxLength, MaxLength,
ValidateNested, ValidateNested,
} from "class-validator"; } from "class-validator";
@@ -26,6 +27,14 @@ export class AddCompanyProfileInputDto {
@IsString() @IsString()
@MaxLength(120) @MaxLength(120)
licenceNumber?: string; licenceNumber?: string;
/**
* Which Ethiopian transit agent the company is. Required for the transit
* agent and freight forwarder roles unless the company is already linked.
*/
@IsOptional()
@IsUUID()
transitAgentId?: string;
} }
export class AddCompanyProfilesDto { export class AddCompanyProfilesDto {

View File

@@ -1,4 +1,4 @@
import { IsEnum, IsOptional, IsString, MaxLength } from 'class-validator'; import { IsEnum, IsOptional, IsString, IsUUID, MaxLength } from 'class-validator';
import { ProfileType } from '../entities/company-profile.entity'; import { ProfileType } from '../entities/company-profile.entity';
export class CreateCompanyProfileDto { export class CreateCompanyProfileDto {
@@ -19,4 +19,12 @@ export class CreateCompanyProfileDto {
@IsString() @IsString()
@MaxLength(120) @MaxLength(120)
licenceNumber?: string; licenceNumber?: string;
/**
* Which Ethiopian transit agent the company is. Required for the transit
* agent and freight forwarder roles unless the company is already linked.
*/
@IsOptional()
@IsUUID()
transitAgentId?: string;
} }

View File

@@ -87,6 +87,13 @@ export class ResponseCompanyDto {
email?: string | null; email?: string | null;
website?: string | null; website?: string | null;
attributes?: Record<string, any> | null; attributes?: Record<string, any> | null;
/**
* The transit-agent roster entry a freight forwarder registered itself as
* (`Company.transitAgentId`). The name rides along when the relation was
* loaded, so the portal and backoffice can show it without a second lookup.
*/
transitAgentId: string | null;
transitAgent: { id: string; name: string } | null;
profiles?: ResponseExternalProfileDto[]; profiles?: ResponseExternalProfileDto[];
companyProfiles?: ResponseCompanyProfileDto[]; companyProfiles?: ResponseCompanyProfileDto[];
/** /**
@@ -144,6 +151,10 @@ export class ResponseCompanyDto {
this.email = company.email; this.email = company.email;
this.website = company.website; this.website = company.website;
this.attributes = company.attributes; this.attributes = company.attributes;
this.transitAgentId = company.transitAgentId ?? null;
this.transitAgent = company.transitAgent
? { id: company.transitAgent.id, name: company.transitAgent.name }
: null;
this.profiles = company.profiles?.map((p) => new ResponseExternalProfileDto(p)); this.profiles = company.profiles?.map((p) => new ResponseExternalProfileDto(p));
this.companyProfiles = company.companyProfiles?.map( this.companyProfiles = company.companyProfiles?.map(
(p) => new ResponseCompanyProfileDto(p), (p) => new ResponseCompanyProfileDto(p),

View File

@@ -4,6 +4,7 @@ import {
IsBoolean, IsBoolean,
IsEnum, IsEnum,
IsOptional, IsOptional,
IsUUID,
} from "class-validator"; } from "class-validator";
import { CompanyNationality, CompanyType } from "../entities/company.entity"; import { CompanyNationality, CompanyType } from "../entities/company.entity";
import { ProfileType } from "../entities/company-profile.entity"; import { ProfileType } from "../entities/company-profile.entity";
@@ -42,4 +43,14 @@ export class StartOnboardingDto {
@IsOptional() @IsOptional()
@IsBoolean() @IsBoolean()
investorLicence?: boolean; investorLicence?: boolean;
/**
* Which Ethiopian transit agent this company is. Required whenever `roles`
* includes the freight forwarder — the two are the same business — and
* refused for any other agent (foreign, suspended, or unknown). Ignored when
* the forwarder role is not selected.
*/
@IsOptional()
@IsUUID()
transitAgentId?: string;
} }

View File

@@ -9,6 +9,14 @@ export enum ProfileType {
freightForwarder = "freight_forwarder", freightForwarder = "freight_forwarder",
djFreightForwarder = "dj_freight_forwarder", djFreightForwarder = "dj_freight_forwarder",
transporter = "transporter", transporter = "transporter",
/**
* An Ethiopian transit agent registering on the portal as itself — the
* business customers pick to clear customs on a booking. Holds no trade
* licence or eTrade business here: its identity is the roster entry
* (`Company.transitAgentId`), and a company with ONLY this role finishes
* onboarding right after picking it.
*/
transitAgent = "transit_agent",
} }
export enum ProfileStatus { export enum ProfileStatus {

View File

@@ -1,5 +1,6 @@
import { BaseEntity } from "@edr/api-common"; import { BaseEntity } from "@edr/api-common";
import { Column, Entity, Index, OneToMany } from "typeorm"; import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany } from "typeorm";
import { TransitAgent } from "../../transit-agents/entities/transit-agent.entity";
import { ExternalProfile } from "./external-profile.entity"; import { ExternalProfile } from "./external-profile.entity";
import { CompanyProfile } from "./company-profile.entity"; import { CompanyProfile } from "./company-profile.entity";
@@ -235,6 +236,22 @@ export class Company extends BaseEntity {
@Column({ name: "etrade_phone", type: "varchar", length: 20, nullable: true }) @Column({ name: "etrade_phone", type: "varchar", length: 20, nullable: true })
etradePhone?: string | null; etradePhone?: string | null;
/**
* The transit-agent roster entry this company IS, when it holds the
* freight-forwarder role. An Ethiopian transit agent and a freight forwarder
* are one business seen from two sides — the roster GL assigns officers
* from, and the customer signing contracts on other companies' behalf — and
* this is what ties the two rows together. Required at onboarding for a
* forwarder; null for every importer/exporter and for forwarders linked
* before the column existed.
*/
@Column({ name: "transit_agent_id", type: "uuid", nullable: true })
transitAgentId?: string | null;
@ManyToOne(() => TransitAgent, { nullable: true })
@JoinColumn({ name: "transit_agent_id" })
transitAgent?: TransitAgent | null;
@OneToMany(() => ExternalProfile, (profile) => profile.company) @OneToMany(() => ExternalProfile, (profile) => profile.company)
profiles?: ExternalProfile[]; profiles?: ExternalProfile[];

View File

@@ -31,6 +31,8 @@ describe('ContractBookingService — quantity-cap completion', () => {
{} as never, // bookingBatchService {} as never, // bookingBatchService
{} as never, // bookingTransitionService {} as never, // bookingTransitionService
{} as never, // consolidationApprovalService {} as never, // consolidationApprovalService
{} as never, // transitAgentsRepository
{} as never, // transitAssignmentsService
); );
return { service, contractsRepository }; return { service, contractsRepository };
} }
@@ -158,7 +160,9 @@ describe('ContractBookingService — quantity-cap completion', () => {
{} as never, {} as never,
{} as never, {} as never,
{} as never, // consolidationApprovalService {} as never, // consolidationApprovalService
); {} as never, // transitAgentsRepository
{} as never, // transitAssignmentsService
);
return { service, contractsRepository }; return { service, contractsRepository };
} }

View File

@@ -65,6 +65,8 @@ describe('ContractBookingService — drawdown consolidation gate', () => {
{} as never, // bookingBatchService {} as never, // bookingBatchService
{} as never, // bookingTransitionService {} as never, // bookingTransitionService
{} as never, // consolidationApprovalService {} as never, // consolidationApprovalService
{} as never, // transitAgentsRepository
{} as never, // transitAssignmentsService
); );
return { return {
service, service,

View File

@@ -27,6 +27,8 @@ describe('ContractBookingService — customs booking gate', () => {
{} as never, // bookingBatchService {} as never, // bookingBatchService
{} as never, // bookingTransitionService {} as never, // bookingTransitionService
{} as never, // consolidationApprovalService {} as never, // consolidationApprovalService
{} as never, // transitAgentsRepository
{} as never, // transitAssignmentsService
); );
} }

View File

@@ -47,6 +47,8 @@ describe('ContractBookingService — manual odd-20ft consolidation', () => {
// The pairing is parked for approval rather than going straight to // The pairing is parked for approval rather than going straight to
// Operations; the gate itself is covered by its own spec. // Operations; the gate itself is covered by its own spec.
{ requestApproval: jest.fn().mockResolvedValue({ id: 'ap-1' }) } as never, { requestApproval: jest.fn().mockResolvedValue({ id: 'ap-1' }) } as never,
{} as never, // transitAgentsRepository
{} as never, // transitAssignmentsService
); );
return { service, bookingsRepository, dataSource }; return { service, bookingsRepository, dataSource };
} }

View File

@@ -60,6 +60,8 @@ describe('ContractBookingService — changes-requested resubmit restating cargo'
{} as never, // bookingBatchService {} as never, // bookingBatchService
{} as never, // bookingTransitionService {} as never, // bookingTransitionService
{} as never, // consolidationApprovalService {} as never, // consolidationApprovalService
{} as never, // transitAgentsRepository
{} as never, // transitAssignmentsService
); );
return { service, bookingsRepository, invoiceService }; return { service, bookingsRepository, invoiceService };
} }

View File

@@ -20,6 +20,9 @@ import { BookingsRepository } from '../bookings/bookings.repository';
import { BookingPricingService } from '../bookings/booking-pricing.service'; import { BookingPricingService } from '../bookings/booking-pricing.service';
import { BookingTransitionService } from '../bookings/booking-transition.service'; import { BookingTransitionService } from '../bookings/booking-transition.service';
import { BookingLifecycleNotifierService } from '../bookings/booking-lifecycle-notifier.service'; import { BookingLifecycleNotifierService } from '../bookings/booking-lifecycle-notifier.service';
import { TransitAgentCountry } from '../transit-agents/entities/transit-agent.entity';
import { TransitAgentsRepository } from '../transit-agents/transit-agents.repository';
import { TransitAssignmentsService } from '../transit-assignments/transit-assignments.service';
import { ConsolidationService } from '../bookings/consolidation.service'; import { ConsolidationService } from '../bookings/consolidation.service';
import { ConsolidationApprovalService } from '../bookings/consolidation-approval.service'; import { ConsolidationApprovalService } from '../bookings/consolidation-approval.service';
import { PriceLineItemDto } from '../bookings/dto/generate-price-response.dto'; import { PriceLineItemDto } from '../bookings/dto/generate-price-response.dto';
@@ -136,6 +139,8 @@ export class ContractBookingService {
private readonly bookingTransitionService: BookingTransitionService, private readonly bookingTransitionService: BookingTransitionService,
@Inject(forwardRef(() => ConsolidationApprovalService)) @Inject(forwardRef(() => ConsolidationApprovalService))
private readonly consolidationApprovalService: ConsolidationApprovalService, private readonly consolidationApprovalService: ConsolidationApprovalService,
private readonly transitAgentsRepository: TransitAgentsRepository,
private readonly transitAssignmentsService: TransitAssignmentsService,
) {} ) {}
async createUnderContract( async createUnderContract(
@@ -857,34 +862,66 @@ export class ContractBookingService {
if (!dto.scheduledDate) { if (!dto.scheduledDate) {
throw new BadRequestException('A binding shipment day is required'); throw new BadRequestException('A binding shipment day is required');
} }
// Without-customs import/export: the customer's own clearing agent (name, // Without-customs import/export: the customer names who clears customs for
// email, phone) is captured per booking at completion. A resubmit may omit // this booking, one of two ways. Either a registered Ethiopian transit
// the fields and keep what the booking already stored. Customs contracts // agent (a freight forwarder on the platform) — the booking is assigned to
// (GL clears) and intercity (no border) never collect an agent. // it and the forwarder is told — or their own clearing agent typed in
// (name, email, phone). A resubmit may omit the typed fields and keep what
// the booking already stored. Customs contracts (GL clears) and intercity
// (no border) never collect an agent.
let assignedTransitAgent: { id: string; name: string } | null = null;
if ( if (
!contract.customsClearingEnabled && !contract.customsClearingEnabled &&
contract.tradeDirection !== 'DOMESTIC' contract.tradeDirection !== 'DOMESTIC'
) { ) {
const agentName = if (dto.transitAgentId) {
dto.customsClearingAgent?.trim() || booking.customsClearingAgent || null; const agent = await this.transitAgentsRepository.findById(dto.transitAgentId);
const agentEmail = if (
dto.customsClearingAgentEmail?.trim() || !agent ||
booking.customsClearingAgentEmail || !agent.isActive ||
null; agent.country !== TransitAgentCountry.Ethiopia
const agentPhone = ) {
dto.customsClearingAgentPhone?.trim() || throw new BadRequestException(
booking.customsClearingAgentPhone || 'The selected transit agent is not an active Ethiopian transit agent — pick another one or enter your clearing agent details.',
null; );
if (!agentName || !agentEmail || !agentPhone) { }
throw new BadRequestException( // The forwarder company's own contact goes on the booking, so the
'Customs clearing agent name, email and phone are required to complete this booking.', // customer sees who to reach; an Ethiopian agent row carries none.
); const [forwarder]: Array<{ email: string | null; phone: string | null }> =
await this.dataSource.query(
`SELECT email, phone FROM freight.companies
WHERE transit_agent_id = $1 AND deleted_at IS NULL
LIMIT 1`,
[agent.id],
);
await this.bookingsRepository.update(booking.id, {
customsClearingAgent: agent.name,
customsClearingAgentEmail: forwarder?.email ?? null,
customsClearingAgentPhone: forwarder?.phone ?? null,
} as never);
assignedTransitAgent = { id: agent.id, name: agent.name };
} else {
const agentName =
dto.customsClearingAgent?.trim() || booking.customsClearingAgent || null;
const agentEmail =
dto.customsClearingAgentEmail?.trim() ||
booking.customsClearingAgentEmail ||
null;
const agentPhone =
dto.customsClearingAgentPhone?.trim() ||
booking.customsClearingAgentPhone ||
null;
if (!agentName || !agentEmail || !agentPhone) {
throw new BadRequestException(
'Customs clearing agent name, email and phone are required to complete this booking — or pick a registered transit agent.',
);
}
await this.bookingsRepository.update(booking.id, {
customsClearingAgent: agentName,
customsClearingAgentEmail: agentEmail,
customsClearingAgentPhone: agentPhone,
} as never);
} }
await this.bookingsRepository.update(booking.id, {
customsClearingAgent: agentName,
customsClearingAgentEmail: agentEmail,
customsClearingAgentPhone: agentPhone,
} as never);
} }
// No expiry gate here on purpose: this booking was already initiated // No expiry gate here on purpose: this booking was already initiated
// before the contract lapsed (createUnderContract/initiateUnderContract // before the contract lapsed (createUnderContract/initiateUnderContract
@@ -1092,6 +1129,18 @@ export class ContractBookingService {
dto.scheduledDate, dto.scheduledDate,
dto.trainScheduleId ?? null, dto.trainScheduleId ?? null,
); );
// The forwarder's work list and its notice come AFTER the booking is
// committed: a customer must never be told a forwarder has the job when
// the completion itself was refused a line above.
if (assignedTransitAgent) {
await this.transitAssignmentsService.ensureAssignment(
booking.id,
assignedTransitAgent.id,
(actorPermissions as { id?: string } | undefined)?.id,
);
void this.bookingNotifier.transitAgentAssigned(completed, assignedTransitAgent);
}
return { booking: completed, warnings }; return { booking: completed, warnings };
} }

View File

@@ -256,6 +256,17 @@ export class CreateBookingUnderContractDto {
@MaxLength(50) @MaxLength(50)
customsClearingAgentPhone?: string; customsClearingAgentPhone?: string;
@ApiPropertyOptional({
format: 'uuid',
description:
'Instead of typing a clearing agent: a registered Ethiopian transit agent (freight ' +
'forwarder). The booking is assigned to it and the forwarder is notified; the typed ' +
'agent fields are ignored when this is set.',
})
@IsOptional()
@IsUUID()
transitAgentId?: string;
@ApiPropertyOptional() @ApiPropertyOptional()
@IsOptional() @IsOptional()
@IsString() @IsString()

View File

@@ -130,6 +130,7 @@ export const customersDataset: ExportDataset = {
{ value: 'exporter', label: 'Exporter' }, { value: 'exporter', label: 'Exporter' },
{ value: 'freight_forwarder', label: 'Freight forwarder' }, { value: 'freight_forwarder', label: 'Freight forwarder' },
{ value: 'dj_freight_forwarder', label: 'DJ freight forwarder' }, { value: 'dj_freight_forwarder', label: 'DJ freight forwarder' },
{ value: 'transit_agent', label: 'Transit agent' },
{ value: 'transporter', label: 'Transporter' }, { value: 'transporter', label: 'Transporter' },
] }, ] },
// The list's Status filter folds the review queues in, and sends these two // The list's Status filter folds the review queues in, and sends these two

View File

@@ -2,14 +2,15 @@ import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { Transform } from "class-transformer"; import { Transform } from "class-transformer";
import { import {
IsBoolean, IsBoolean,
IsDateString,
IsEmail, IsEmail,
IsEnum,
IsOptional, IsOptional,
IsString, IsString,
MaxLength, MaxLength,
} from "class-validator"; } from "class-validator";
import { IsValidPhone } from "../../../common/validators/is-phone-number.validator"; import { IsValidPhone } from "../../../common/validators/is-phone-number.validator";
import { TransitAgentCountry } from "../entities/transit-agent.entity";
const toBoolean = ({ value }: { value: unknown }) => { const toBoolean = ({ value }: { value: unknown }) => {
if (typeof value === "boolean") return value; if (typeof value === "boolean") return value;
@@ -24,13 +25,18 @@ export class CreateTransitAgentDto {
@MaxLength(150) @MaxLength(150)
name!: string; name!: string;
@ApiProperty({ example: "2026-01-01" }) /**
@IsDateString() * Defaults to Djibouti, which is what the whole roster was before Ethiopian
validFrom!: string; * agents were added. Only `ET` agents are offered to a freight forwarder
* picking itself during onboarding.
@ApiProperty({ example: "2026-12-31" }) */
@IsDateString() @ApiPropertyOptional({
validTo!: string; enum: TransitAgentCountry,
default: TransitAgentCountry.Djibouti,
})
@IsOptional()
@IsEnum(TransitAgentCountry)
country?: TransitAgentCountry;
@ApiPropertyOptional({ default: true }) @ApiPropertyOptional({ default: true })
@IsOptional() @IsOptional()

View File

@@ -2,22 +2,38 @@ import { BaseEntity } from "@edr/api-common";
import { Column, Entity, Index } from "typeorm"; import { Column, Entity, Index } from "typeorm";
/** /**
* Djibouti transit officer GL Djibouti may assign against a shipment's * Where the agent is licensed. The roster started Djibouti-only (the officers
* transit-assignee handshake. Admin-managed so the roster and each officer's * GL Djibouti assigns), so that is the column default. An Ethiopian transit
* validity window arrive without a code change; `isActive` is the manual * agent is the same business as a freight forwarder — a forwarder onboarding on
* suspend/reactivate switch, independent of the validity window. * the portal picks itself from the `ET` entries (`Company.transitAgentId`).
*/
export enum TransitAgentCountry {
Ethiopia = "ET",
Djibouti = "DJ",
}
/**
* Transit officer GL Djibouti may assign against a shipment's transit-assignee
* handshake, and — for the Ethiopian entries — the roster a freight forwarder
* registers itself against. Admin-managed so the roster arrives without a code
* change; `isActive` is the manual suspend/reactivate switch and the only
* thing that decides whether an agent may be assigned or picked.
*/ */
@Entity({ schema: "freight", name: "transit_agents" }) @Entity({ schema: "freight", name: "transit_agents" })
@Index(["isActive"]) @Index(["isActive"])
@Index(["country"])
export class TransitAgent extends BaseEntity { export class TransitAgent extends BaseEntity {
@Column({ name: "name", type: "varchar", length: 150 }) @Column({ name: "name", type: "varchar", length: 150 })
name!: string; name!: string;
@Column({ name: "valid_from", type: "date" }) @Column({
validFrom!: string; name: "country",
type: "varchar",
@Column({ name: "valid_to", type: "date" }) length: 2,
validTo!: string; enum: TransitAgentCountry,
default: TransitAgentCountry.Djibouti,
})
country!: TransitAgentCountry;
@Column({ name: "is_active", type: "boolean", default: true }) @Column({ name: "is_active", type: "boolean", default: true })
isActive!: boolean; isActive!: boolean;

View File

@@ -13,6 +13,7 @@ import {
} from "@nestjs/common"; } from "@nestjs/common";
import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger"; import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
import { PortalCustomer } from "../../common/booking-guards";
import { import {
RuleEngineCreate, RuleEngineCreate,
RuleEngineDelete, RuleEngineDelete,
@@ -50,16 +51,29 @@ export class TransitAgentsController {
}); });
} }
/** Active + currently valid officers — the transit-assignee assignment dropdown. */ /** Active officers — the transit-assignee assignment dropdown. */
@Get("assignable") @Get("assignable")
@RuleEngineView("transit-agents") @RuleEngineView("transit-agents")
@ApiOperation({ @ApiOperation({ summary: "List active transit agents (assignable)" })
summary: "List transit agents assignable right now (active and in-window)",
})
findAssignable() { findAssignable() {
return this.transitAgentsService.findAssignable(); return this.transitAgentsService.findAssignable();
} }
/**
* The Ethiopian roster, id + name only, for a customer registering as a
* freight forwarder to pick itself from. Declared before `:id` so the
* literal path is not swallowed by the UUID route.
*/
@Get("forwarder-options")
@PortalCustomer()
@ApiOperation({
summary:
"List active Ethiopian transit agents (id + name) a freight forwarder can register as",
})
findForwarderOptions() {
return this.transitAgentsService.findForwarderOptions();
}
@Get(":id") @Get(":id")
@RuleEngineView("transit-agents") @RuleEngineView("transit-agents")
@ApiOperation({ summary: "Get a transit agent by ID" }) @ApiOperation({ summary: "Get a transit agent by ID" })

View File

@@ -1,14 +1,15 @@
import { BaseRepository } from "@edr/api-common"; import { BaseRepository } from "@edr/api-common";
import { Injectable } from "@nestjs/common"; import { Injectable } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm"; import { InjectRepository } from "@nestjs/typeorm";
import { import { EntityManager, Repository } from "typeorm";
EntityManager,
LessThanOrEqual,
MoreThanOrEqual,
Repository,
} from "typeorm";
import { TransitAgent } from "./entities/transit-agent.entity"; import {
TransitAgent,
TransitAgentCountry,
} from "./entities/transit-agent.entity";
/** What a forwarder picking itself from the roster needs: the id and a label. */
export type ForwarderTransitAgentOption = Pick<TransitAgent, "id" | "name">;
@Injectable() @Injectable()
export class TransitAgentsRepository extends BaseRepository<TransitAgent> { export class TransitAgentsRepository extends BaseRepository<TransitAgent> {
@@ -19,14 +20,23 @@ export class TransitAgentsRepository extends BaseRepository<TransitAgent> {
super(repository); super(repository);
} }
/** Active AND currently inside its validity window (today's date, server-side). */ /** Every active agent — the GL assignment dropdown. */
findAssignable(today: string): Promise<TransitAgent[]> { findAssignable(): Promise<TransitAgent[]> {
return this.repository.find({ return this.repository.find({
where: { where: { isActive: true },
isActive: true, order: { name: "ASC" },
validFrom: LessThanOrEqual(today), });
validTo: MoreThanOrEqual(today), }
},
/**
* The Ethiopian roster a freight forwarder registers itself against, as
* `{ id, name }` only — this is served to customers, who have no business
* seeing another agent's email or phone. Suspended agents are left out.
*/
findForwarderOptions(): Promise<ForwarderTransitAgentOption[]> {
return this.repository.find({
select: { id: true, name: true },
where: { isActive: true, country: TransitAgentCountry.Ethiopia },
order: { name: "ASC" }, order: { name: "ASC" },
}); });
} }

View File

@@ -5,11 +5,12 @@ import {
} from "@tria-plc/api-common/utils/enums/user.enum"; } from "@tria-plc/api-common/utils/enums/user.enum";
import { ResetChannel } from "../auth/dto/forgot-password.dto"; import { ResetChannel } from "../auth/dto/forgot-password.dto";
import { TransitAgentCountry } from "./entities/transit-agent.entity";
import { TransitAgentsService } from "./transit-agents.service"; import { TransitAgentsService } from "./transit-agents.service";
/** /**
* The account half of a transit agent. The roster half (validity window, * The account half of a transit agent. The roster half (assignability)
* assignability) predates this and is untouched — what these lock is that * predates this and is untouched — what these lock is that
* adding a login did not make an account MANDATORY, since production is full of * adding a login did not make an account MANDATORY, since production is full of
* roster-only agents that must keep working. * roster-only agents that must keep working.
*/ */
@@ -36,8 +37,6 @@ describe("TransitAgentsService accounts", () => {
const base = { const base = {
name: "Ahmed Bourhan", name: "Ahmed Bourhan",
validFrom: "2026-01-01",
validTo: "2026-12-31",
}; };
beforeEach(() => { beforeEach(() => {
@@ -343,4 +342,102 @@ describe("TransitAgentsService accounts", () => {
expect(dataSource.transaction).not.toHaveBeenCalled(); expect(dataSource.transaction).not.toHaveBeenCalled();
}); });
}); });
/**
* An Ethiopian transit agent IS a freight forwarder, which signs up on the
* portal with its own email and phone. Nothing minted from this side may
* claim those first.
*/
describe("Ethiopian agents carry no contact details or account", () => {
const ethiopian = { ...base, country: TransitAgentCountry.Ethiopia };
it("refuses an email on create", async () => {
await expect(
service.createWithInvite({ ...ethiopian, email: "ff@example.et" }),
).rejects.toThrow(BadRequestException);
expect(repo.create).not.toHaveBeenCalled();
expect(dataSource.transaction).not.toHaveBeenCalled();
});
it("refuses a phone number on create", async () => {
await expect(
service.createWithInvite({ ...ethiopian, phoneNumber: "+251911223344" }),
).rejects.toThrow(BadRequestException);
expect(repo.create).not.toHaveBeenCalled();
});
it("creates the roster entry with neither", async () => {
const { agent } = await service.createWithInvite(ethiopian);
expect(agent.country).toBe(TransitAgentCountry.Ethiopia);
expect(agent.hasAccount).toBe(false);
});
it("refuses to invite one", async () => {
repo.findById.mockResolvedValue({
id: "ta-1",
...ethiopian,
isActive: true,
userId: null,
});
await expect(
service.invite("ta-1", { email: "ff@example.et" }),
).rejects.toThrow(BadRequestException);
expect(dataSource.transaction).not.toHaveBeenCalled();
});
it("refuses an email on update", async () => {
repo.findById.mockResolvedValue({
id: "ta-1",
...ethiopian,
isActive: true,
userId: null,
});
await expect(
service.update("ta-1", { email: "ff@example.et" }),
).rejects.toThrow(BadRequestException);
expect(repo.update).not.toHaveBeenCalled();
});
it("clears the contact details when a Djiboutian row is switched", async () => {
repo.findById.mockResolvedValue({
id: "ta-1",
...base,
country: TransitAgentCountry.Djibouti,
isActive: true,
userId: null,
email: "a@transit.dj",
phoneNumber: "+25377834567",
});
await service.update("ta-1", { country: TransitAgentCountry.Ethiopia });
expect(repo.update).toHaveBeenCalledWith(
"ta-1",
expect.objectContaining({
country: TransitAgentCountry.Ethiopia,
email: null,
phoneNumber: null,
}),
);
});
it("refuses the switch when the row already has a portal account", async () => {
repo.findById.mockResolvedValue({
id: "ta-1",
...base,
country: TransitAgentCountry.Djibouti,
isActive: true,
userId: "user-1",
email: "a@transit.dj",
});
await expect(
service.update("ta-1", { country: TransitAgentCountry.Ethiopia }),
).rejects.toThrow(BadRequestException);
expect(repo.update).not.toHaveBeenCalled();
});
});
}); });

View File

@@ -26,13 +26,16 @@ import { isDomesticPhone } from "../otp/otp.service";
import { CreateTransitAgentDto } from "./dto/create-transit-agent.dto"; import { CreateTransitAgentDto } from "./dto/create-transit-agent.dto";
import { InviteTransitAgentDto } from "./dto/invite-transit-agent.dto"; import { InviteTransitAgentDto } from "./dto/invite-transit-agent.dto";
import { UpdateTransitAgentDto } from "./dto/update-transit-agent.dto"; import { UpdateTransitAgentDto } from "./dto/update-transit-agent.dto";
import { TransitAgent } from "./entities/transit-agent.entity"; import {
import { TransitAgentsRepository } from "./transit-agents.repository"; TransitAgent,
TransitAgentCountry,
export type TransitAgentValidityStatus = "VALID" | "NOT_STARTED" | "EXPIRED"; } from "./entities/transit-agent.entity";
import {
ForwarderTransitAgentOption,
TransitAgentsRepository,
} from "./transit-agents.repository";
export type TransitAgentView = TransitAgent & { export type TransitAgentView = TransitAgent & {
validityStatus: TransitAgentValidityStatus;
/** True once an IAM account backs this agent — i.e. it can sign in. */ /** True once an IAM account backs this agent — i.e. it can sign in. */
hasAccount: boolean; hasAccount: boolean;
}; };
@@ -52,24 +55,9 @@ type TransitAgentListFilter = {
sortOrder?: string; sortOrder?: string;
}; };
/** Today as `yyyy-MM-dd`, matching the `date`-typed validity columns. */ function toView(agent: TransitAgent): TransitAgentView {
function todayISODate(): string {
return new Date().toISOString().slice(0, 10);
}
function validityStatus(
agent: Pick<TransitAgent, "validFrom" | "validTo">,
): TransitAgentValidityStatus {
const today = todayISODate();
if (today < agent.validFrom) return "NOT_STARTED";
if (today > agent.validTo) return "EXPIRED";
return "VALID";
}
function withValidityStatus(agent: TransitAgent): TransitAgentView {
return { return {
...agent, ...agent,
validityStatus: validityStatus(agent),
hasAccount: Boolean(agent.userId), hasAccount: Boolean(agent.userId),
}; };
} }
@@ -92,9 +80,7 @@ export class TransitAgentsService {
}> { }> {
const page = filter.page ?? 1; const page = filter.page ?? 1;
const pageSize = filter.pageSize ?? 500; const pageSize = filter.pageSize ?? 500;
const sortBy = ["name", "validFrom", "validTo", "isActive"].includes( const sortBy = ["name", "country", "isActive"].includes(filter.sortBy ?? "")
filter.sortBy ?? "",
)
? (filter.sortBy as keyof TransitAgent) ? (filter.sortBy as keyof TransitAgent)
: "name"; : "name";
const sortOrder = const sortOrder =
@@ -108,7 +94,7 @@ export class TransitAgentsService {
}); });
return { return {
data: data.map(withValidityStatus), data: data.map(toView),
meta: { meta: {
total, total,
page, page,
@@ -118,9 +104,14 @@ export class TransitAgentsService {
}; };
} }
/** Active and currently inside its validity window — the DJ assignment dropdown. */ /** Every active agent — the DJ assignment dropdown. */
async findAssignable(): Promise<TransitAgent[]> { async findAssignable(): Promise<TransitAgent[]> {
return this.transitAgentsRepository.findAssignable(todayISODate()); return this.transitAgentsRepository.findAssignable();
}
/** The Ethiopian roster a freight forwarder picks itself from at onboarding. */
findForwarderOptions(): Promise<ForwarderTransitAgentOption[]> {
return this.transitAgentsRepository.findForwarderOptions();
} }
async findById(id: string): Promise<TransitAgentView> { async findById(id: string): Promise<TransitAgentView> {
@@ -128,10 +119,10 @@ export class TransitAgentsService {
if (!agent) { if (!agent) {
throw new NotFoundException(`Transit agent ${id} not found`); throw new NotFoundException(`Transit agent ${id} not found`);
} }
return withValidityStatus(agent); return toView(agent);
} }
/** Used by the assignment flow — rejects a suspended or out-of-window officer. */ /** Used by the assignment flow — rejects a suspended officer. */
async getAssignable(id: string): Promise<TransitAgent> { async getAssignable(id: string): Promise<TransitAgent> {
const agent = await this.transitAgentsRepository.findById(id); const agent = await this.transitAgentsRepository.findById(id);
if (!agent) { if (!agent) {
@@ -142,11 +133,6 @@ export class TransitAgentsService {
`${agent.name} is suspended — pick another transit officer.`, `${agent.name} is suspended — pick another transit officer.`,
); );
} }
if (validityStatus(agent) !== "VALID") {
throw new BadRequestException(
`${agent.name}'s validity window has expired — pick another transit officer or extend their dates.`,
);
}
return agent; return agent;
} }
@@ -221,6 +207,32 @@ export class TransitAgentsService {
return { email, username, phoneNumber }; return { email, username, phoneNumber };
} }
/**
* An Ethiopian transit agent never gets a portal account of its own.
*
* It IS a freight forwarder, and the forwarder signs up on the portal as a
* customer with its own email and phone — the same ones staff would type
* here. An IAM account minted from this side would then claim that email
* first, and the forwarder's own registration would fail with "already
* registered". So for `ET` the contact fields are refused outright, and the
* invite path is closed.
*/
private assertNoAccountForEthiopian(
country: TransitAgentCountry,
dto: {
email?: string | null;
phoneNumber?: string | null;
username?: string;
},
): void {
if (country !== TransitAgentCountry.Ethiopia) return;
if (dto.email || dto.phoneNumber || dto.username) {
throw new BadRequestException(
"An Ethiopian transit agent has no email, phone or portal account here — it registers itself on the portal as a freight forwarder with its own contact details.",
);
}
}
/** /**
* Create a transit agent. * Create a transit agent.
* *
@@ -237,24 +249,18 @@ export class TransitAgentsService {
async createWithInvite( async createWithInvite(
dto: CreateTransitAgentDto, dto: CreateTransitAgentDto,
): Promise<InvitedTransitAgent> { ): Promise<InvitedTransitAgent> {
if (dto.validTo < dto.validFrom) {
throw new BadRequestException(
"Valid-to date must be on or after valid-from date.",
);
}
const base = { const base = {
name: dto.name.trim(), name: dto.name.trim(),
validFrom: dto.validFrom, country: dto.country ?? TransitAgentCountry.Djibouti,
validTo: dto.validTo,
isActive: dto.isActive ?? true, isActive: dto.isActive ?? true,
}; };
this.assertNoAccountForEthiopian(base.country, dto);
if (!dto.email) { if (!dto.email) {
// Roster-only agent — no account, nothing to send. // Roster-only agent — no account, nothing to send.
const agent = await this.transitAgentsRepository.create(base); const agent = await this.transitAgentsRepository.create(base);
return { return {
agent: withValidityStatus(agent), agent: toView(agent),
activationSentTo: null, activationSentTo: null,
activationChannel: null, activationChannel: null,
}; };
@@ -286,7 +292,7 @@ export class TransitAgentsService {
// valid without it. // valid without it.
const activation = await this.sendActivationLink(agent); const activation = await this.sendActivationLink(agent);
return { return {
agent: withValidityStatus(agent), agent: toView(agent),
activationSentTo: activation?.maskedTarget ?? null, activationSentTo: activation?.maskedTarget ?? null,
activationChannel: activation?.channel ?? null, activationChannel: activation?.channel ?? null,
}; };
@@ -312,6 +318,7 @@ export class TransitAgentsService {
"This transit agent already has a portal account — resend the activation link instead.", "This transit agent already has a portal account — resend the activation link instead.",
); );
} }
this.assertNoAccountForEthiopian(current.country, dto);
const { email, username, phoneNumber } = await this.prepareAccountFields( const { email, username, phoneNumber } = await this.prepareAccountFields(
dto, dto,
@@ -338,7 +345,7 @@ export class TransitAgentsService {
const activation = await this.sendActivationLink(agent); const activation = await this.sendActivationLink(agent);
return { return {
agent: withValidityStatus(agent), agent: toView(agent),
activationSentTo: activation?.maskedTarget ?? null, activationSentTo: activation?.maskedTarget ?? null,
activationChannel: activation?.channel ?? null, activationChannel: activation?.channel ?? null,
}; };
@@ -436,13 +443,6 @@ export class TransitAgentsService {
dto: UpdateTransitAgentDto, dto: UpdateTransitAgentDto,
): Promise<TransitAgentView> { ): Promise<TransitAgentView> {
const current = await this.findById(id); const current = await this.findById(id);
const nextValidFrom = dto.validFrom ?? current.validFrom;
const nextValidTo = dto.validTo ?? current.validTo;
if (nextValidTo < nextValidFrom) {
throw new BadRequestException(
"Valid-to date must be on or after valid-from date.",
);
}
// `username` only ever names an IAM account, and it is chosen once at // `username` only ever names an IAM account, and it is chosen once at
// account creation. Accepting it here (PartialType inherits it from the // account creation. Accepting it here (PartialType inherits it from the
@@ -450,6 +450,21 @@ export class TransitAgentsService {
const { username: _ignoredUsername, email, phoneNumber, ...rest } = dto; const { username: _ignoredUsername, email, phoneNumber, ...rest } = dto;
const contact: Partial<TransitAgent> = {}; const contact: Partial<TransitAgent> = {};
const nextCountry = dto.country ?? current.country;
if (nextCountry === TransitAgentCountry.Ethiopia) {
this.assertNoAccountForEthiopian(nextCountry, { email, phoneNumber });
if (current.userId) {
// The account already holds the email the forwarder would sign up
// with; there is no way to hand it back, so the row stays Djiboutian.
throw new BadRequestException(
`${current.name} already has a portal account, so it cannot become an Ethiopian transit agent — create a new Ethiopian entry instead.`,
);
}
// Whatever contact details a Djiboutian row carried go with the switch,
// so the forwarder's own registration cannot collide with them.
contact.email = null;
contact.phoneNumber = null;
}
if (email !== undefined) { if (email !== undefined) {
const normalized = email.trim().toLowerCase(); const normalized = email.trim().toLowerCase();
if (await this.transitAgentsRepository.existsByEmail(normalized, id)) { if (await this.transitAgentsRepository.existsByEmail(normalized, id)) {
@@ -483,7 +498,7 @@ export class TransitAgentsService {
await this.syncIamContact(updated); await this.syncIamContact(updated);
} }
return withValidityStatus(updated); return toView(updated);
} }
/** /**

View File

@@ -2,6 +2,7 @@ import { Module } from "@nestjs/common";
import { TypeOrmModule } from "@nestjs/typeorm"; import { TypeOrmModule } from "@nestjs/typeorm";
import { Booking } from "../bookings/entities/booking.entity"; import { Booking } from "../bookings/entities/booking.entity";
import { ExternalProfile } from "../companies/entities/external-profile.entity";
import { ClearanceMilestone } from "../contracts/entities/clearance-milestone.entity"; import { ClearanceMilestone } from "../contracts/entities/clearance-milestone.entity";
import { FilesModule } from "../files/files.module"; import { FilesModule } from "../files/files.module";
import { TrainSchedule } from "../train-schedules/entities/train-schedule.entity"; import { TrainSchedule } from "../train-schedules/entities/train-schedule.entity";
@@ -19,11 +20,14 @@ import { TransitAssignmentsService } from "./transit-assignments.service";
// Milestones and train schedules are read for the agent's dashboard // Milestones and train schedules are read for the agent's dashboard
// timings (declaration stamps, departure/arrival fallbacks) — entities // timings (declaration stamps, departure/arrival fallbacks) — entities
// only, for the same reason as Booking. // only, for the same reason as Booking.
// ExternalProfile: `/my` resolves a freight forwarder's portal user to the
// transit agent its company registered as — entity only, same reason.
TypeOrmModule.forFeature([ TypeOrmModule.forFeature([
TransitAssignment, TransitAssignment,
Booking, Booking,
ClearanceMilestone, ClearanceMilestone,
TrainSchedule, TrainSchedule,
ExternalProfile,
]), ]),
FilesModule, FilesModule,
TransitAgentsModule, TransitAgentsModule,

View File

@@ -93,6 +93,7 @@ describe("TransitAssignmentsService", () => {
files as never, files as never,
milestones as never, milestones as never,
trainSchedules as never, trainSchedules as never,
{ findOne: jest.fn().mockResolvedValue(null) } as never, // externalProfiles
); );
}); });

View File

@@ -17,6 +17,11 @@ import {
} from "@edr/types"; } from "@edr/types";
import { Booking } from "../bookings/entities/booking.entity"; import { Booking } from "../bookings/entities/booking.entity";
import { ExternalProfile } from "../companies/entities/external-profile.entity";
import {
ProfileStatus,
ProfileType,
} from "../companies/entities/company-profile.entity";
import { ClearanceMilestone } from "../contracts/entities/clearance-milestone.entity"; import { ClearanceMilestone } from "../contracts/entities/clearance-milestone.entity";
import { FilesService } from "../files/files.service"; import { FilesService } from "../files/files.service";
import { TrainSchedule } from "../train-schedules/entities/train-schedule.entity"; import { TrainSchedule } from "../train-schedules/entities/train-schedule.entity";
@@ -170,6 +175,11 @@ export class TransitAssignmentsService {
private readonly milestonesRepository: Repository<ClearanceMilestone>, private readonly milestonesRepository: Repository<ClearanceMilestone>,
@InjectRepository(TrainSchedule) @InjectRepository(TrainSchedule)
private readonly trainSchedulesRepository: Repository<TrainSchedule>, private readonly trainSchedulesRepository: Repository<TrainSchedule>,
// The ExternalProfile ENTITY (not CompaniesModule) for the same reason as
// Booking above: `/my` only has to walk portal user → company → the
// transit agent that company registered itself as.
@InjectRepository(ExternalProfile)
private readonly externalProfilesRepository: Repository<ExternalProfile>,
) {} ) {}
private static minutesBetween( private static minutesBetween(
@@ -252,9 +262,52 @@ export class TransitAssignmentsService {
// client-supplied id: an agent must not be able to read or edit another // client-supplied id: an agent must not be able to read or edit another
// agent's assignments by guessing one. // agent's assignments by guessing one.
/** The transit agent this portal user signs in as. */ /**
private async requireAgentForUser(userId: string) { * The transit agent this portal user acts as.
const agent = await this.transitAgentsRepository.findByUserId(userId); *
* Two kinds of account reach `/my`: a Djibouti transit officer, who signs in
* AS the agent (`transit_agents.user_id`), and a customer company that
* registered itself as an Ethiopian transit agent (`companies.
* transit_agent_id`) — under the transit agent role, the forwarder role, or
* both. It may look at its assigned bookings from the moment the role is
* requested — that is how it learns work is waiting — but may only act on
* them (`forWrite`) once a roster role has been approved.
*/
private async requireAgentForUser(
userId: string,
opts: { forWrite?: boolean } = {},
) {
const own = await this.transitAgentsRepository.findByUserId(userId);
if (own) return own;
const profile = await this.externalProfilesRepository.findOne({
where: { userId },
relations: { company: { companyProfiles: true } },
});
const company = profile?.company;
if (!company?.transitAgentId) {
throw new ForbiddenException("This account is not a transit agent");
}
// Either roster role will do — a plain transit agent or a forwarder.
const agentRoles = (company.companyProfiles ?? []).filter(
(p) =>
p.type === ProfileType.transitAgent ||
p.type === ProfileType.freightForwarder,
);
if (agentRoles.length === 0) {
throw new ForbiddenException("This account is not a transit agent");
}
if (
opts.forWrite &&
!agentRoles.some((p) => p.status === ProfileStatus.Active)
) {
throw new ForbiddenException(
"Your transit agent role is not approved yet — you can view assigned bookings but not act on them until it is.",
);
}
const agent = await this.transitAgentsRepository.findById(
company.transitAgentId,
);
if (!agent) { if (!agent) {
throw new ForbiddenException("This account is not a transit agent"); throw new ForbiddenException("This account is not a transit agent");
} }
@@ -640,8 +693,12 @@ export class TransitAssignmentsService {
return { ...this.toView(assignment), files: await this.listFiles(id) }; return { ...this.toView(assignment), files: await this.listFiles(id) };
} }
/** Assert the assignment is this user's before any write reaches it. */ /**
* Assert the assignment is this user's before any write reaches it — and
* that the user may write at all (an unapproved forwarder may only look).
*/
private async assertMine(userId: string, id: string): Promise<void> { private async assertMine(userId: string, id: string): Promise<void> {
await this.requireAgentForUser(userId, { forWrite: true });
await this.findMineById(userId, id); await this.findMineById(userId, id);
} }
@@ -677,6 +734,7 @@ export class TransitAssignmentsService {
id: string, id: string,
input: { finish: boolean; note?: string }, input: { finish: boolean; note?: string },
): Promise<TransitAssignmentView> { ): Promise<TransitAssignmentView> {
await this.requireAgentForUser(userId, { forWrite: true });
const current = await this.findMineById(userId, id); const current = await this.findMineById(userId, id);
if (current.status === TransitAssignmentStatus.Finished) { if (current.status === TransitAssignmentStatus.Finished) {
throw new ForbiddenException("This assignment is already finished."); throw new ForbiddenException("This assignment is already finished.");

View File

@@ -166,14 +166,14 @@ export function TransitAssigneePanel({
) : null} ) : null}
<Select <Select
label="Transit officer" 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"} placeholder={loadingAgents ? "Loading…" : "Select transit officer"}
data={agentOptions} data={agentOptions}
value={transitAgentId} value={transitAgentId}
onChange={setTransitAgentId} onChange={setTransitAgentId}
searchable searchable
disabled={readOnly || loadingAgents} 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"> <Group justify="flex-end" gap="sm">
{changing ? ( {changing ? (

View File

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

View File

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

View File

@@ -954,6 +954,22 @@ export default function CustomerDetailPage() {
<InfoField label="Email" value={company.email} /> <InfoField label="Email" value={company.email} />
<InfoField label="Phone" value={company.phone} /> <InfoField label="Phone" value={company.phone} />
<InfoField label="Website" value={company.website} /> <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 <InfoField
label="Submitted on" label="Submitted on"
value={formatDate(company.createdAt)} value={formatDate(company.createdAt)}

View File

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

View File

@@ -613,7 +613,10 @@ const RuleEngineResourcePage = () => {
</Button> </Button>
</Tooltip> </Tooltip>
) : null} ) : 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 <TransitAgentAccountAction
agent={row.original as unknown as TransitAgent} agent={row.original as unknown as TransitAgent}
disabled={!canUpdateControls} disabled={!canUpdateControls}

View File

@@ -10,8 +10,8 @@ export type ColumnFormat =
| "boolean" | "boolean"
| "activeBadge" | "activeBadge"
| "rateStatus" | "rateStatus"
| "validityBadge"
| "accountBadge" | "accountBadge"
| "country"
| "date" | "date"
| "number" | "number"
| "currency" | "currency"
@@ -667,21 +667,14 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
label: "Transit Agents", label: "Transit Agents",
category: "configuration", category: "configuration",
subtitle: 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...", searchPlaceholder: "Search transit agents by name...",
cardTitleKey: "name", cardTitleKey: "name",
columns: [ columns: [
{ id: "name", header: "Name", accessorKey: "name" }, { id: "name", header: "Name", accessorKey: "name" },
{ id: "country", header: "Country", accessorKey: "country", format: "country" },
{ id: "email", header: "Email", accessorKey: "email" }, { id: "email", header: "Email", accessorKey: "email" },
{ id: "phoneNumber", header: "Phone", accessorKey: "phoneNumber" }, { 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", id: "hasAccount",
header: "Portal account", header: "Portal account",
@@ -692,19 +685,27 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
], ],
formFields: [ formFields: [
{ name: "name", label: "Name", type: "text", required: true }, { name: "name", label: "Name", type: "text", required: true },
{ name: "validFrom", label: "Valid from", type: "date", required: true },
{ {
name: "validTo", name: "country",
label: "Valid to", label: "Country",
type: "date", type: "select",
required: true, 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", name: "email",
label: "Email", label: "Email",
type: "email", type: "email",
optional: true, optional: true,
hideWhen: { field: "country", equals: ["ET"] },
description: 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.", "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", label: "Phone number",
type: "phone", type: "phone",
optional: true, 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 { export interface TransitAgent {
id: string; id: string;
name: string; name: string;
validFrom: string; country: "ET" | "DJ";
validTo: string;
isActive: boolean; isActive: boolean;
/** Null on every agent that exists only as a GL-assignable roster entry. */ /** Null on every agent that exists only as a GL-assignable roster entry. */
email?: string | null; email?: string | null;
@@ -29,7 +28,7 @@ export interface ActivationSendResult {
} }
export const transitAgentsService = { export const transitAgentsService = {
/** Active + currently inside its validity window — the assignment dropdown. */ /** Every active agent — the assignment dropdown. */
async listAssignable() { async listAssignable() {
const response = await apiClient.get<TransitAgent[]>( const response = await apiClient.get<TransitAgent[]>(
URL_CONSTANTS.RULE_ENGINE.TRANSIT_AGENTS_ASSIGNABLE, URL_CONSTANTS.RULE_ENGINE.TRANSIT_AGENTS_ASSIGNABLE,

View File

@@ -32,7 +32,8 @@ export type ProfileType =
| "exporter" | "exporter"
| "freight_forwarder" | "freight_forwarder"
| "dj_freight_forwarder" | "dj_freight_forwarder"
| "transporter"; | "transporter"
| "transit_agent";
/** Mirrors backend `ProfileStatus`. */ /** Mirrors backend `ProfileStatus`. */
export type ProfileStatus = export type ProfileStatus =
@@ -280,6 +281,13 @@ export interface Company {
* them against the investment licence on the Documents tab. * them against the investment licence on the Documents tab.
*/ */
investorLicence?: boolean; 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; address?: string | null;
phone?: string | null; phone?: string | null;
email?: string | null; email?: string | null;

View File

@@ -1,6 +1,7 @@
import { AppLayout, type SidebarItem } from "@/components/AppLayout"; import { AppLayout, type SidebarItem } from "@/components/AppLayout";
import { useDisclosure } from "@mantine/hooks"; import { useDisclosure } from "@mantine/hooks";
import { import {
ClipboardList,
Home, Home,
Layers, Layers,
LayoutDashboard, LayoutDashboard,
@@ -74,6 +75,7 @@ import {
TransitAgentBookingDetailPage, TransitAgentBookingDetailPage,
TransitAgentOverviewPage, TransitAgentOverviewPage,
} from "./pages/transit-agent"; } from "./pages/transit-agent";
import { AssignedBookingsPage } from "./pages/forwarder";
import FaqPage from "./pages/support/FaqPage"; import FaqPage from "./pages/support/FaqPage";
import HelpPage from "./pages/support/HelpPage"; import HelpPage from "./pages/support/HelpPage";
import PrivacyPolicyPage from "./pages/support/PrivacyPolicyPage"; 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. * captured, so there is nothing for them to onboard — they go straight to home.
*/ */
function OnboardingGate() { function OnboardingGate() {
const { company, onboardingCompleted, isShippingLine, isTransitAgent } = const {
useAuth(); company,
onboardingCompleted,
isShippingLine,
isTransitAgent,
isTransitAgentOnly,
} = useAuth();
const location = useLocation(); const location = useLocation();
// Keyed off a positive shipping-line / transit-agent identification, never // Keyed off a positive shipping-line / transit-agent identification, never
@@ -186,6 +193,14 @@ function OnboardingGate() {
if (needsOnboarding && !allowedHere) { if (needsOnboarding && !allowedHere) {
return <Navigate to="/portal" replace />; 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 ( return (
<> <>
@@ -242,7 +257,8 @@ function RequireTransitAgent() {
* still in flight, which would land a shipping line on the customer home first. * still in flight, which would land a shipping line on the customer home first.
*/ */
function useHomeRoute(): { ready: boolean; href: string } { function useHomeRoute(): { ready: boolean; href: string } {
const { isShippingLine, isTransitAgent, customerQuery } = useAuth(); const { isShippingLine, isTransitAgent, isTransitAgentOnly, customerQuery } =
useAuth();
return { return {
ready: !customerQuery.isPending, ready: !customerQuery.isPending,
@@ -250,7 +266,9 @@ function useHomeRoute(): { ready: boolean; href: string } {
? "/shipping-line" ? "/shipping-line"
: isTransitAgent : isTransitAgent
? "/transit-agent" ? "/transit-agent"
: "/portal", : isTransitAgentOnly
? ASSIGNED_BOOKINGS_PATH
: "/portal",
}; };
} }
@@ -280,6 +298,30 @@ function LandingRoute() {
return <EDRFreightLandingPage />; 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[] = [ const sidebarItems: SidebarItem[] = [
{ label: "Home", href: "/portal", icon: <Home size={18} /> }, { label: "Home", href: "/portal", icon: <Home size={18} /> },
{ {
@@ -377,6 +419,8 @@ const App = () => {
isAuthenticated, isAuthenticated,
isShippingLine, isShippingLine,
isTransitAgent, isTransitAgent,
canSeeAssignedBookings,
isTransitAgentOnly,
} = useAuth(); } = useAuth();
// Attribute replays and exceptions to the signed-in user (id/org only). // 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 displayName = user?.name?.en || user?.username || user?.email || "User";
const userEmail = user?.email; const userEmail = user?.email;
const companyProfiles = company?.company?.companyProfiles ?? []; 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 ( return (
<> <>
@@ -562,14 +620,24 @@ const App = () => {
element={ element={
<AppLayout <AppLayout
title="EDR Freight" title="EDR Freight"
sidebarItems={sidebarItems} sidebarItems={customerSidebarItems}
activeHref={location.pathname} activeHref={location.pathname}
onNavigate={navigate} onNavigate={navigate}
userName={displayName} userName={displayName}
userEmail={userEmail} userEmail={userEmail}
companyProfiles={companyProfiles} companyProfiles={companyProfiles}
companyType={companyType} companyType={companyType}
onCreateProfile={createProfile} companyTransitAgentId={
company?.company?.transitAgentId ?? null
}
onCreateProfile={(type, files, options) =>
createProfile(
type,
files,
undefined,
options?.transitAgentId,
)
}
onReapplyProfile={reapplyProfile} onReapplyProfile={reapplyProfile}
> >
<OnboardingGate /> <OnboardingGate />
@@ -601,6 +669,12 @@ const App = () => {
path="/bookings/:id/contract" path="/bookings/:id/contract"
element={<BookingContractPage />} 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" element={<ContractsList />} />
<Route path="/contracts/new" element={<NewContractPage />} /> <Route path="/contracts/new" element={<NewContractPage />} />
<Route <Route

View File

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

View File

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

View File

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

View File

@@ -9,4 +9,5 @@ export const PROFILE_TYPE_LABELS: Record<string, string> = {
freight_forwarder: "Freight Forwarder", freight_forwarder: "Freight Forwarder",
dj_freight_forwarder: "DJ Freight Forwarder", dj_freight_forwarder: "DJ Freight Forwarder",
transporter: "Transporter", 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. // as long as they have at least one backoffice-approved operational role.
const companyProfiles = companyInfo?.company?.companyProfiles ?? []; const companyProfiles = companyInfo?.company?.companyProfiles ?? [];
const hasActiveProfile = companyProfiles.some((p) => p.status === "active"); 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 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 // Profile-edit review: while a change request is pending the customer is
// locked out of editing and of creating new contracts/bookings; a rejected // 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. * by the API for any company that has an eTrade record.
*/ */
licenceNumber?: string, licenceNumber?: string,
/** The roster entry, for the transit agent / forwarder roles. */
transitAgentId?: string,
): Promise<Result<void>> => { ): Promise<Result<void>> => {
try { try {
const created = await api.companies.createCompanyProfile.call({ const created = await api.companies.createCompanyProfile.call({
type, type,
licenceNumber, licenceNumber,
transitAgentId,
}); });
if (licenseFiles.length > 0) { if (licenseFiles.length > 0) {
await companiesService.uploadProfileLicense(created.id, licenseFiles); await companiesService.uploadProfileLicense(created.id, licenseFiles);
@@ -316,6 +341,9 @@ const useAuth = () => {
canBook, canBook,
hasActiveProfile, hasActiveProfile,
hasPendingProfile, hasPendingProfile,
canSeeAssignedBookings,
assignedBookingsUnlocked,
isTransitAgentOnly,
companyType, companyType,
companyStatus, companyStatus,
isCompanyApproved, isCompanyApproved,

View File

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

View File

@@ -10,6 +10,7 @@ import { useForm, Controller } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useNavigate, useParams } from "react-router-dom"; import { useNavigate, useParams } from "react-router-dom";
import TransitAgentSelect from "@/components/onboarding/TransitAgentSelect";
import { import {
ActionIcon, ActionIcon,
Alert, Alert,
@@ -23,6 +24,7 @@ import {
Loader, Loader,
Modal, Modal,
Paper, Paper,
SegmentedControl,
Stack, Stack,
Switch, Switch,
Text, Text,
@@ -630,15 +632,22 @@ function NewShipmentBookingForm({
? { requestedWagons: Number(values.requestedWagons) } ? { requestedWagons: Number(values.requestedWagons) }
: {}), : {}),
}), }),
// Customer's own clearing agent — collected at completion; the server // Who clears customs — collected at completion of a without-customs
// requires all three for a without-customs import/export booking. // import/export booking. Either a registered transit agent (the booking
...(values.customsClearingAgent?.trim() // is assigned to that forwarder) or the customer's own agent, for which
? { // the server requires all three fields. Never both.
customsClearingAgent: values.customsClearingAgent.trim(), ...(values.clearingAgentMode === "transit_agent" &&
customsClearingAgentEmail: values.customsClearingAgentEmail.trim(), values.transitAgentId?.trim()
customsClearingAgentPhone: values.customsClearingAgentPhone.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 } : {}), ...(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 * Completion of a without-customs import/export booking: the customer names
* their own customs clearing agent per booking — name, email and phone are * who clears customs for it, one of two ways. Pick a registered Ethiopian
* all required (the schema and the server both enforce it). * 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 }) { function ClearingAgentStep({ form }: { form: ShipmentForm }) {
const mode = form.watch("clearingAgentMode");
return ( return (
<StepCard> <StepCard>
<StepHeader <StepHeader
icon={<FileText size={22} />} icon={<FileText size={22} />}
title="Customs Clearing Agent" 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"> <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 <Controller
name="customsClearingAgent" name="customsClearingAgent"
control={form.control} control={form.control}
@@ -2135,6 +2192,8 @@ function ClearingAgentStep({ form }: { form: ShipmentForm }) {
)} )}
/> />
</Group> </Group>
</>
) : null}
</Stack> </Stack>
</StepCard> </StepCard>
); );

View File

@@ -119,6 +119,10 @@ const shipmentFormBase = z.object({
customsClearingAgent: z.string().default(""), customsClearingAgent: z.string().default(""),
customsClearingAgentEmail: z.string().default(""), customsClearingAgentEmail: z.string().default(""),
customsClearingAgentPhone: 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(""), 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()) { if (!data.customsClearingAgent.trim()) {
refineCtx.addIssue({ refineCtx.addIssue({
code: "custom", code: "custom",
@@ -407,6 +419,8 @@ export const initialShipmentFormValues: DeepPartial<ShipmentFormValues> = {
customsClearingAgent: "", customsClearingAgent: "",
customsClearingAgentEmail: "", customsClearingAgentEmail: "",
customsClearingAgentPhone: "", customsClearingAgentPhone: "",
clearingAgentMode: "manual",
transitAgentId: "",
notes: "", 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, businessLabel,
useEtradeBusinesses, useEtradeBusinesses,
} from "@/components/onboarding/EtradeBusinessSelect"; } 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 { CompanyProfileResponse } from "@/services/companies.service";
import type { ProfileResponse } from "@/types/profile"; import type { ProfileResponse } from "@/types/profile";
import RoleCard from "./RoleCard"; import RoleCard from "./RoleCard";
@@ -52,6 +58,11 @@ function roleStatusView(p: CompanyProfileResponse): {
export default function CompanyRolesCard({ profile }: CompanyRolesCardProps) { export default function CompanyRolesCard({ profile }: CompanyRolesCardProps) {
const queryClient = useQueryClient(); 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( const options = useMemo(
() => rolesForCompanyType(profile.companyType), () => rolesForCompanyType(profile.companyType),
@@ -115,11 +126,13 @@ export default function CompanyRolesCard({ profile }: CompanyRolesCardProps) {
profiles: types.map((type) => ({ profiles: types.map((type) => ({
type, type,
licenceNumber: licenceByType[type], licenceNumber: licenceByType[type],
...(isAgentRole(type) && transitAgentId ? { transitAgentId } : {}),
})), })),
}), }),
onSuccess: () => { onSuccess: () => {
setSelected(new Set()); setSelected(new Set());
setLicenceByType({}); setLicenceByType({});
setTransitAgentId(null);
queryClient.invalidateQueries({ queryClient.invalidateQueries({
queryKey: api.companies.getProfile.queryKey(), 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 // Every selected licensed role needs its business named first — the API
// added without one, so the button is what tells the user, not a 400. // 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 = const missingLicence =
businessRequired && 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 = () => { const handleSave = () => {
if (selected.size === 0 || missingLicence) return; if (selected.size === 0 || missingLicence || missingAgent) return;
mutation.mutate(Array.from(selected)); mutation.mutate(Array.from(selected));
}; };
@@ -164,7 +185,7 @@ export default function CompanyRolesCard({ profile }: CompanyRolesCardProps) {
</Group> </Group>
<Text c="edr-muted" size="sm" mb="lg"> <Text c="edr-muted" size="sm" mb="lg">
{profile.companyType === "customer" {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."} : "Your company's operational role."}
</Text> </Text>
@@ -252,7 +273,9 @@ export default function CompanyRolesCard({ profile }: CompanyRolesCardProps) {
Say which of your eTrade business licences each new role operates as. Say which of your eTrade business licences each new role operates as.
</Text> </Text>
{options {options
.filter((opt) => selected.has(opt.type)) .filter(
(opt) => selected.has(opt.type) && opt.type !== "transit_agent",
)
.map((opt) => ( .map((opt) => (
<EtradeBusinessSelect <EtradeBusinessSelect
key={opt.type} key={opt.type}
@@ -269,6 +292,16 @@ export default function CompanyRolesCard({ profile }: CompanyRolesCardProps) {
</Stack> </Stack>
)} )}
{agentRequired && (
<Stack gap="sm" mt="lg">
<TransitAgentSelect
value={transitAgentId}
onChange={setTransitAgentId}
disabled={mutation.isPending}
/>
</Stack>
)}
{options.length > 0 && ( {options.length > 0 && (
<Group <Group
justify="space-between" justify="space-between"
@@ -298,7 +331,7 @@ export default function CompanyRolesCard({ profile }: CompanyRolesCardProps) {
type="button" type="button"
leftSection={<Save size={16} />} leftSection={<Save size={16} />}
loading={mutation.isPending} loading={mutation.isPending}
disabled={selected.size === 0 || missingLicence} disabled={selected.size === 0 || missingLicence || missingAgent}
onClick={handleSave} onClick={handleSave}
> >
{selected.size > 1 ? "Add Roles" : "Add Role"} {selected.size > 1 ? "Add Roles" : "Add Role"}

View File

@@ -49,6 +49,7 @@ const ROLE_LABELS: Record<string, string> = {
exporter: "Exporter", exporter: "Exporter",
freight_forwarder: "Freight Forwarder", freight_forwarder: "Freight Forwarder",
dj_freight_forwarder: "DJ Freight Forwarder", dj_freight_forwarder: "DJ Freight Forwarder",
transit_agent: "Transit Agent",
transporter: "Transporter", 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 { export interface RoleMeta {
type: string; type: string;
@@ -28,12 +33,29 @@ export const FREIGHT_FORWARDER: RoleMeta = {
icon: <Building2 size={22} />, 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 * Importer / Exporter / Freight Forwarder / Transit Agent — the services a
* can hold. A single company may register for any combination, each getting its * "customer" company can hold. A single company may register for any
* own business license. * 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. // dj_freight_forwarder and transporter are intentionally not exposed yet.
export function rolesForCompanyType(companyType: string): RoleMeta[] { export function rolesForCompanyType(companyType: string): RoleMeta[] {

View File

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

View File

@@ -12,7 +12,8 @@ export type ProfileTypeValue =
| "exporter" | "exporter"
| "freight_forwarder" | "freight_forwarder"
| "dj_freight_forwarder" | "dj_freight_forwarder"
| "transporter"; | "transporter"
| "transit_agent";
export type CompanyNationality = "ethiopian" | "foreign"; export type CompanyNationality = "ethiopian" | "foreign";
@@ -69,11 +70,24 @@ export interface CompanyResponse {
email: string | null; email: string | null;
website: string | null; website: string | null;
attributes: Record<string, any> | 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[]; companyProfiles?: CompanyProfileResponse[];
createdAt: string; createdAt: string;
updatedAt: 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 { export interface CompanyProfileResponse {
id: string; id: string;
type: string; type: string;
@@ -149,8 +163,6 @@ export interface TransitAgentInfoResponse {
email: string | null; email: string | null;
phoneNumber: string | null; phoneNumber: string | null;
isActive: boolean; isActive: boolean;
validFrom: string;
validTo: string;
company: null; company: null;
profile: null; profile: null;
review: null; review: null;
@@ -369,7 +381,11 @@ export const companiesService = {
}, },
addCompanyProfiles: async (payload: { addCompanyProfiles: async (payload: {
profiles: { type: string; licenceNumber?: string }[]; profiles: {
type: string;
licenceNumber?: string;
transitAgentId?: string;
}[];
}): Promise<CompanyProfileResponse[]> => { }): Promise<CompanyProfileResponse[]> => {
const response = await client.post<ApiResponse<CompanyProfileResponse[]>>( const response = await client.post<ApiResponse<CompanyProfileResponse[]>>(
URL_CONSTANTS.COMPANIES_API.COMPANY_PROFILES, URL_CONSTANTS.COMPANIES_API.COMPANY_PROFILES,
@@ -383,6 +399,7 @@ export const companiesService = {
type: ProfileTypeValue; type: ProfileTypeValue;
businessLicense?: string; businessLicense?: string;
licenceNumber?: string; licenceNumber?: string;
transitAgentId?: string;
}): Promise<CompanyProfileResponse> => { }): Promise<CompanyProfileResponse> => {
const response = await client.post<ApiResponse<CompanyProfileResponse>>( const response = await client.post<ApiResponse<CompanyProfileResponse>>(
URL_CONSTANTS.COMPANIES_API.COMPANY_PROFILE, URL_CONSTANTS.COMPANIES_API.COMPANY_PROFILE,
@@ -421,6 +438,7 @@ export const companiesService = {
nationality?: CompanyNationality; nationality?: CompanyNationality;
cooperative?: boolean; cooperative?: boolean;
investorLicence?: boolean; investorLicence?: boolean;
transitAgentId?: string;
}): Promise<CompanyInfoResponse> => { }): Promise<CompanyInfoResponse> => {
const response = await client.post<ApiResponse<CompanyInfoResponse>>( const response = await client.post<ApiResponse<CompanyInfoResponse>>(
URL_CONSTANTS.COMPANIES_API.ONBOARDING_START, URL_CONSTANTS.COMPANIES_API.ONBOARDING_START,
@@ -429,6 +447,20 @@ export const companiesService = {
return unwrap(response.data); 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. * Give up the foreign investment-licence route and go back through eTrade.
* The API clears the typed registration and reopens onboarding at the company * The API clears the typed registration and reopens onboarding at the company

View File

@@ -1053,6 +1053,12 @@ export interface CreateBookingUnderContractDto {
customsClearingAgent?: string; customsClearingAgent?: string;
customsClearingAgentEmail?: string; customsClearingAgentEmail?: string;
customsClearingAgentPhone?: string; customsClearingAgentPhone?: string;
/**
* The alternative to typing a clearing agent: a registered Ethiopian transit
* agent (a freight forwarder on the platform). Assigns the booking to it and
* notifies the forwarder company; the typed agent fields are then ignored.
*/
transitAgentId?: string;
notes?: string; notes?: string;
} }