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,9 +518,15 @@ 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:
transitAgentOnly && linkedAgent
? linkedAgent.name
: identity.firstName
? `${identity.firstName}'s company` ? `${identity.firstName}'s company`
: "New company", : "New company",
type: companyType, type: companyType,
@@ -473,6 +534,7 @@ export class CompaniesService {
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,10 +2349,27 @@ 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
// yet: its first licensed role attaches the business on the wizard's
// licence step, once the TIN has been looked up, exactly like a role
// picked at onboarding.
const etradeBusiness =
type === ProfileType.transitAgent || wasTransitAgentOnly
? null
: await this.resolveProfileBusiness(
company, company,
inputs.find((i) => i.type === type)?.licenceNumber, inputs.find((i) => i.type === type)?.licenceNumber,
type, type,
@@ -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,6 +160,8 @@ 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,14 +862,45 @@ 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'
) { ) {
if (dto.transitAgentId) {
const agent = await this.transitAgentsRepository.findById(dto.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 enter your clearing agent details.',
);
}
// The forwarder company's own contact goes on the booking, so the
// 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 = const agentName =
dto.customsClearingAgent?.trim() || booking.customsClearingAgent || null; dto.customsClearingAgent?.trim() || booking.customsClearingAgent || null;
const agentEmail = const agentEmail =
@@ -877,7 +913,7 @@ export class ContractBookingService {
null; null;
if (!agentName || !agentEmail || !agentPhone) { if (!agentName || !agentEmail || !agentPhone) {
throw new BadRequestException( throw new BadRequestException(
'Customs clearing agent name, email and phone are required to complete this booking.', '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, { await this.bookingsRepository.update(booking.id, {
@@ -886,6 +922,7 @@ export class ContractBookingService {
customsClearingAgentPhone: agentPhone, customsClearingAgentPhone: agentPhone,
} as never); } 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
// already checked expiry at start). Finishing an in-flight booking must // already checked expiry at start). Finishing an in-flight booking must
@@ -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,6 +266,8 @@ function useHomeRoute(): { ready: boolean; href: string } {
? "/shipping-line" ? "/shipping-line"
: isTransitAgent : isTransitAgent
? "/transit-agent" ? "/transit-agent"
: isTransitAgentOnly
? ASSIGNED_BOOKINGS_PATH
: "/portal", : "/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,6 +931,8 @@ 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.`
: createTarget === "transit_agent"
? "You don't have a transit agent profile yet. Pick your company from EDR's transit agent list to create one — it goes to EDR for approval before bookings assigned to you can be worked on."
: `You don't have a ${serviceLabel( : `You don't have a ${serviceLabel(
createTarget, createTarget,
).toLowerCase()} profile yet. Add your business license to create one — it goes to EDR for approval before you can operate under it.`} ).toLowerCase()} profile yet. Add your business license to create one — it goes to EDR for approval before you can operate under it.`}
@@ -910,6 +947,15 @@ export function AppLayout({
{reapplyNote} {reapplyNote}
</Alert> </Alert>
)} )}
{!reapplyId && agentNeeded ? (
<TransitAgentSelect
value={transitAgentId}
onChange={setTransitAgentId}
disabled={switching}
error={createError && !transitAgentId ? createError : undefined}
/>
) : null}
{licenceApplies ? (
<FileInput <FileInput
label={ label={
reapplyId ? "Business license (optional)" : "Business license" reapplyId ? "Business license (optional)" : "Business license"
@@ -923,6 +969,11 @@ export function AppLayout({
onChange={(files) => setLicenseFiles(files ?? [])} onChange={(files) => setLicenseFiles(files ?? [])}
error={createError ?? undefined} 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,19 +375,34 @@ 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 handleRolesContinue = useCallback(() => { const needsAgent = needsAgentFor(roles);
const transitAgentOnly = isTransitAgentOnlyRoles(roles);
const startDraft = useCallback(
(agentId: string | null) => {
setStartError(null); setStartError(null);
startMutation.mutate({ startMutation.mutate({
companyType: companyTypeForRoles(roles), companyType: companyTypeForRoles(roles),
@@ -344,8 +410,27 @@ export default function OnboardingWizardDialog({
nationality: nationality ?? undefined, nationality: nationality ?? undefined,
cooperative, cooperative,
investorLicence, investorLicence,
...(agentId ? { transitAgentId: agentId } : {}),
}); });
}, [roles, nationality, cooperative, investorLicence, startMutation]); },
[roles, nationality, cooperative, investorLicence, startMutation],
);
// A roster role has one more question before its draft exists; everyone
// else goes straight to the draft.
const handleRolesContinue = useCallback(() => {
setStartError(null);
if (needsAgent) {
setPhase("transit-agent");
return;
}
startDraft(null);
}, [needsAgent, startDraft]);
const handleTransitAgentContinue = useCallback(() => {
if (!transitAgentId) return;
startDraft(transitAgentId);
}, [transitAgentId, startDraft]);
// Back from the form's first step returns to nationality/role selection. // 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,13 +632,20 @@ 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.
...(values.clearingAgentMode === "transit_agent" &&
values.transitAgentId?.trim()
? { transitAgentId: values.transitAgentId.trim() }
: values.customsClearingAgent?.trim()
? { ? {
customsClearingAgent: values.customsClearingAgent.trim(), customsClearingAgent: values.customsClearingAgent.trim(),
customsClearingAgentEmail: values.customsClearingAgentEmail.trim(), customsClearingAgentEmail:
customsClearingAgentPhone: values.customsClearingAgentPhone.trim(), 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;
} }