mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
@@ -82,8 +82,8 @@ import { FreightPermissionKeyMigrationSeeder } from "./seed/freight-permission-k
|
|||||||
import { FreightNotificationPermissionsSeeder } from "./seed/freight-notification-permissions.seeder";
|
import { FreightNotificationPermissionsSeeder } from "./seed/freight-notification-permissions.seeder";
|
||||||
// import { DemoFreightDataSeeder } from "./seed/demo-freight-data.seeder";
|
// import { DemoFreightDataSeeder } from "./seed/demo-freight-data.seeder";
|
||||||
// import { GovCompaniesSeeder } from "./seed/gov-companies.seeder";
|
// import { GovCompaniesSeeder } from "./seed/gov-companies.seeder";
|
||||||
import { ApprovedFirstLastMileDemoBookingsSeeder } from "./seed/approved-first-lastmile-demo-bookings.seeder";
|
// import { ApprovedFirstLastMileDemoBookingsSeeder } from "./seed/approved-first-lastmile-demo-bookings.seeder";
|
||||||
import { PaidImportExportMileDemoSeeder } from "./seed/paid-import-export-mile-demo.seeder";
|
// import { PaidImportExportMileDemoSeeder } from "./seed/paid-import-export-mile-demo.seeder";
|
||||||
//New Trains, Wagons, Container and Cargo management modules
|
//New Trains, Wagons, Container and Cargo management modules
|
||||||
import { TrainsModule } from "./modules/trains/trains.module";
|
import { TrainsModule } from "./modules/trains/trains.module";
|
||||||
import { VerifaydaModule } from "./modules/verifayda/verifayda.module";
|
import { VerifaydaModule } from "./modules/verifayda/verifayda.module";
|
||||||
@@ -281,8 +281,8 @@ if (!process.env.APPLICATION_NAME) {
|
|||||||
// WarehouseDemoSeeder,
|
// WarehouseDemoSeeder,
|
||||||
// ExportDjiboutiInterchangeDemoSeeder,
|
// ExportDjiboutiInterchangeDemoSeeder,
|
||||||
// MarshallingDemoTrainsSeeder,
|
// MarshallingDemoTrainsSeeder,
|
||||||
ApprovedFirstLastMileDemoBookingsSeeder,
|
// ApprovedFirstLastMileDemoBookingsSeeder,
|
||||||
PaidImportExportMileDemoSeeder,
|
// PaidImportExportMileDemoSeeder,
|
||||||
LoginAudienceMiddleware,
|
LoginAudienceMiddleware,
|
||||||
// Feeds position-TYPE grants to the synchronous permission checks — without
|
// Feeds position-TYPE grants to the synchronous permission checks — without
|
||||||
// it, staff whose permissions live on their position type resolve to none.
|
// it, staff whose permissions live on their position type resolve to none.
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
import { Booking } from './entities/booking.entity';
|
import { Booking } from './entities/booking.entity';
|
||||||
import { NotificationsService } from '../notifications/notifications.service';
|
import { NotificationsService } from '../notifications/notifications.service';
|
||||||
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
|
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
|
||||||
import { resolveCompanyNotifyPhone } from '../notifications/resolve-company-phone.util';
|
import { resolveCompanyNotifyContact } from '../notifications/resolve-company-phone.util';
|
||||||
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -55,10 +55,13 @@ export class BookingLifecycleNotifierService {
|
|||||||
logLabel: string,
|
logLabel: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
this.logger.log(`${logLabel} — ${this.ref(b)}`);
|
this.logger.log(`${logLabel} — ${this.ref(b)}`);
|
||||||
const phone = b.companyId
|
// Both channels come from the same resolver: the company row's own columns
|
||||||
? await resolveCompanyNotifyPhone(this.dataSource, b.companyId)
|
// are only half the story (see companyNotifyEmailExpr), and reading them off
|
||||||
: null;
|
// the loaded entity silently dropped every mail to a company whose address
|
||||||
const email = b.company?.email ?? b.company?.generalManagerEmail ?? null;
|
// lives in `attributes`.
|
||||||
|
const { phone, email } = b.companyId
|
||||||
|
? await resolveCompanyNotifyContact(this.dataSource, b.companyId)
|
||||||
|
: { phone: null, email: null };
|
||||||
|
|
||||||
if (phone) {
|
if (phone) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -92,6 +92,7 @@ interface CarriageAcceptanceWagonRow {
|
|||||||
interface CarriageAcceptanceReceivedRow {
|
interface CarriageAcceptanceReceivedRow {
|
||||||
allocatedWeightTons: string | null;
|
allocatedWeightTons: string | null;
|
||||||
containerNumbers: string | null;
|
containerNumbers: string | null;
|
||||||
|
sealNumbers?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const URGENT_PRIORITY_THRESHOLD = 1000;
|
const URGENT_PRIORITY_THRESHOLD = 1000;
|
||||||
@@ -291,15 +292,19 @@ export class BookingsService {
|
|||||||
if (pendingWagons) {
|
if (pendingWagons) {
|
||||||
// Direct truck-to-train cargo never enters the warehouse, so there is no
|
// Direct truck-to-train cargo never enters the warehouse, so there is no
|
||||||
// GRN'd inventory to build the sheet from. Choosing direct handover is
|
// GRN'd inventory to build the sheet from. Choosing direct handover is
|
||||||
// itself the acceptance, so the sheet issues off the booking's own
|
// itself the acceptance, so the sheet issues off the containers the
|
||||||
// containers (or its VGM weight when the cargo is bulk).
|
// customer declared on the booking — freight.containers only gains rows at
|
||||||
|
// allocation, by which point the wagon query above already serves.
|
||||||
const receivedLines: CarriageAcceptanceReceivedRow[] = isDirectExport
|
const receivedLines: CarriageAcceptanceReceivedRow[] = isDirectExport
|
||||||
? await this.dataSource.query(
|
? await this.dataSource.query(
|
||||||
`SELECT NULL::numeric AS "allocatedWeightTons",
|
`SELECT NULL::numeric AS "allocatedWeightTons",
|
||||||
c.container_number AS "containerNumbers"
|
unit.container_number AS "containerNumbers",
|
||||||
FROM freight.containers c
|
unit.seal_number AS "sealNumbers"
|
||||||
WHERE c.booking_id = $1 AND c.deleted_at IS NULL
|
FROM freight.booking_container_units unit
|
||||||
ORDER BY c.container_number`,
|
JOIN freight.booking_container line
|
||||||
|
ON line.id = unit.booking_container_id AND line.deleted_at IS NULL
|
||||||
|
WHERE line.booking_id = $1 AND unit.deleted_at IS NULL
|
||||||
|
ORDER BY unit.container_number`,
|
||||||
[bookingId],
|
[bookingId],
|
||||||
)
|
)
|
||||||
: booking.tradeDirection === 'EXPORT'
|
: booking.tradeDirection === 'EXPORT'
|
||||||
@@ -319,11 +324,13 @@ export class BookingsService {
|
|||||||
)
|
)
|
||||||
: [];
|
: [];
|
||||||
// Bulk direct cargo has no containers — one line carrying the booking's
|
// Bulk direct cargo has no containers — one line carrying the booking's
|
||||||
// declared weight still makes a valid sheet.
|
// declared weight still makes a valid sheet. bulkTotalWeightTons only
|
||||||
|
// holds the real tonnage for PER_ITEM break-bulk; everywhere else (PER_TON
|
||||||
|
// bulk and every container booking) the VGM column is the weight.
|
||||||
if (isDirectExport && receivedLines.length === 0) {
|
if (isDirectExport && receivedLines.length === 0) {
|
||||||
|
const totalWeight = booking.bulkTotalWeightTons ?? booking.cargoTotalWeightVgm;
|
||||||
receivedLines.push({
|
receivedLines.push({
|
||||||
allocatedWeightTons:
|
allocatedWeightTons: totalWeight == null ? null : String(totalWeight),
|
||||||
booking.bulkTotalWeightTons == null ? null : String(booking.bulkTotalWeightTons),
|
|
||||||
containerNumbers: null,
|
containerNumbers: null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -347,7 +354,7 @@ export class BookingsService {
|
|||||||
marshalledAt: null,
|
marshalledAt: null,
|
||||||
arrivalAt: null,
|
arrivalAt: null,
|
||||||
containerNumbers: row.containerNumbers,
|
containerNumbers: row.containerNumbers,
|
||||||
sealNumbers: null,
|
sealNumbers: row.sealNumbers ?? null,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -147,6 +147,10 @@ function makeService(overrides: Partial<Ctx> = {}) {
|
|||||||
verifayda: {
|
verifayda: {
|
||||||
completeVerification: jest.fn(async () => ctx.verification),
|
completeVerification: jest.fn(async () => ctx.verification),
|
||||||
},
|
},
|
||||||
|
// Onboarding requirements resolve the nationality's document set through
|
||||||
|
// this; no setting means no company documents, which keeps those cases
|
||||||
|
// focused on the identity/PoA half of the list.
|
||||||
|
fileUploadSettings: { getByCode: jest.fn(async () => null) },
|
||||||
};
|
};
|
||||||
|
|
||||||
const service = new CompaniesService(
|
const service = new CompaniesService(
|
||||||
@@ -157,7 +161,7 @@ function makeService(overrides: Partial<Ctx> = {}) {
|
|||||||
deps.profilesRepo as never,
|
deps.profilesRepo as never,
|
||||||
{} as never,
|
{} as never,
|
||||||
deps.filesService as never,
|
deps.filesService as never,
|
||||||
{} as never,
|
deps.fileUploadSettings as never,
|
||||||
{} as never,
|
{} as never,
|
||||||
deps.companyNotifier as never,
|
deps.companyNotifier as never,
|
||||||
{} as never,
|
{} as never,
|
||||||
@@ -763,6 +767,73 @@ describe("Ethiopian companies verify with Fayda; foreign companies verify identi
|
|||||||
expect(state.gm.email).toBe("legacy@example.com");
|
expect(state.gm.email).toBe("legacy@example.com");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// The mirror image, and the reason the fallback above is gated on the GM
|
||||||
|
// being unverified: a manager Fayda proved but supplied no email for types
|
||||||
|
// one instead, and the portal decides whether to render that input by asking
|
||||||
|
// whether the identity holds one. Reading the typed column back as part of
|
||||||
|
// the verified identity would answer "yes" the moment it was saved — the
|
||||||
|
// input would vanish and a typo could never be corrected.
|
||||||
|
it("keeps a verified GM's typed email out of the verified identity", async () => {
|
||||||
|
const { service, company } = makeService({
|
||||||
|
attributes: {
|
||||||
|
...OWNER_VERIFIED,
|
||||||
|
gmFaydaSub: "gm-sub",
|
||||||
|
gmFaydaVerifiedAt: "2026-07-03T00:00:00.000Z",
|
||||||
|
gmName: "Derartu Tulu",
|
||||||
|
generalManagerName: "Derartu Tulu",
|
||||||
|
generalManagerEmail: "typed@example.com",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const state = service.getCompanyIdentityState(company() as never);
|
||||||
|
|
||||||
|
expect(state.gm.verified).toBe(true);
|
||||||
|
expect(state.gm.name).toBe("Derartu Tulu");
|
||||||
|
expect(state.gm.email).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never stands the account in for a GM Fayda gave no email", async () => {
|
||||||
|
// Deliberate: the account is the person onboarding, not necessarily the
|
||||||
|
// manager. The portal asks for the email instead.
|
||||||
|
const { service, ctx } = makeService({
|
||||||
|
attributes: { ...OWNER_VERIFIED },
|
||||||
|
verification: {
|
||||||
|
purpose: "VERIFY",
|
||||||
|
verified: true,
|
||||||
|
sub: "gm-sub",
|
||||||
|
fullName: "Derartu Tulu",
|
||||||
|
phoneNumber: "+251911222333",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const state = await service.completeIdentityVerification(
|
||||||
|
"user-1",
|
||||||
|
{ subject: "gm", code: "c", state: "s" },
|
||||||
|
{ email: "account@example.com", phoneNumber: "+251911777777" },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(ctx.attributes.gmEmail).toBeUndefined();
|
||||||
|
expect(state.gm.verified).toBe(true);
|
||||||
|
expect(state.gm.email).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts the email typed for a GM whose verification carried none", async () => {
|
||||||
|
const { service, ctx } = makeService({
|
||||||
|
attributes: {
|
||||||
|
...OWNER_VERIFIED,
|
||||||
|
gmFaydaSub: "gm-sub",
|
||||||
|
gmName: "Derartu Tulu",
|
||||||
|
generalManagerName: "Derartu Tulu",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await service.updateProfile("user-1", {
|
||||||
|
generalManagerEmail: "gm@example.com",
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
expect(ctx.attributes.generalManagerEmail).toBe("gm@example.com");
|
||||||
|
});
|
||||||
|
|
||||||
it("does not let an unproven GM block the company from trading", async () => {
|
it("does not let an unproven GM block the company from trading", async () => {
|
||||||
// The GM names who to talk to, not what the company may do. Capturing it
|
// The GM names who to talk to, not what the company may do. Capturing it
|
||||||
// through Fayda changed how it is collected, not whether it gates.
|
// through Fayda changed how it is collected, not whether it gates.
|
||||||
@@ -775,3 +846,94 @@ describe("Ethiopian companies verify with Fayda; foreign companies verify identi
|
|||||||
).resolves.toBeDefined();
|
).resolves.toBeDefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fayda's email and phone claims are optional, so a *verified* representative
|
||||||
|
* can still be missing the details `REQUIRED_POA_FIELDS` demands. The PoA step
|
||||||
|
* renders an input for whatever the verification did not supply — so onboarding
|
||||||
|
* has to report them outstanding, rather than letting a freight forwarder
|
||||||
|
* submit an incomplete representative and be refused its next PoA edit for it.
|
||||||
|
*/
|
||||||
|
describe("onboarding requirements name the PoA details Fayda did not supply", () => {
|
||||||
|
const POA_VERIFIED_NO_CONTACTS = {
|
||||||
|
poaFaydaSub: "poa-sub",
|
||||||
|
poaFaydaVerifiedAt: "2026-07-02T00:00:00.000Z",
|
||||||
|
poaName: "Tirunesh Dibaba",
|
||||||
|
};
|
||||||
|
|
||||||
|
it("reports the missing email and phone for a freight forwarder", async () => {
|
||||||
|
const { service } = makeService({
|
||||||
|
attributes: { ...OWNER_VERIFIED, ...POA_VERIFIED_NO_CONTACTS },
|
||||||
|
profileTypes: [ProfileType.freightForwarder],
|
||||||
|
files: [paper()],
|
||||||
|
});
|
||||||
|
|
||||||
|
const req = await service.getOnboardingRequirements("user-1");
|
||||||
|
|
||||||
|
expect(req.poa.missingFields.map((f) => f.key)).toEqual([
|
||||||
|
"poaEmail",
|
||||||
|
"poaPhone",
|
||||||
|
]);
|
||||||
|
expect(req.poa.complete).toBe(false);
|
||||||
|
expect(req.outstanding).toEqual(
|
||||||
|
expect.arrayContaining(["Add your poa email", "Add your poa phone"]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears once they are typed", async () => {
|
||||||
|
const { service } = makeService({
|
||||||
|
attributes: { ...OWNER_VERIFIED, ...POA_VERIFIED },
|
||||||
|
profileTypes: [ProfileType.freightForwarder],
|
||||||
|
files: [paper()],
|
||||||
|
});
|
||||||
|
|
||||||
|
const req = await service.getOnboardingRequirements("user-1");
|
||||||
|
|
||||||
|
expect(req.poa.missingFields).toEqual([]);
|
||||||
|
expect(req.poa.complete).toBe(true);
|
||||||
|
expect(req.outstanding).not.toContain("Add your poa email");
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fayda's email claim is optional and the GM's verification has no account to
|
||||||
|
// fall back on, so demanding one blocked a manager the government had already
|
||||||
|
// proved. `companyNotifyEmailExpr` resolves the address from the contact
|
||||||
|
// person or the registering account instead, so nothing needs this filled.
|
||||||
|
it("does not hold a company back for a general manager with no email", async () => {
|
||||||
|
const { service } = makeService({
|
||||||
|
attributes: {
|
||||||
|
...OWNER_VERIFIED,
|
||||||
|
gmFaydaSub: "gm-sub",
|
||||||
|
generalManagerName: "Derartu Tulu",
|
||||||
|
generalManagerPhone: "+251911222333",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const req = await service.getOnboardingRequirements("user-1");
|
||||||
|
|
||||||
|
expect(req.companyInfo.missingFields.map((f) => f.key)).not.toContain(
|
||||||
|
"generalManagerEmail",
|
||||||
|
);
|
||||||
|
expect(req.outstanding).not.toContain("Add your general manager email");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still holds it back for the manager's name and phone", async () => {
|
||||||
|
const { service } = makeService({ attributes: { ...OWNER_VERIFIED } });
|
||||||
|
|
||||||
|
const req = await service.getOnboardingRequirements("user-1");
|
||||||
|
|
||||||
|
expect(req.companyInfo.missingFields.map((f) => f.key)).toEqual(
|
||||||
|
expect.arrayContaining(["generalManagerName", "generalManagerPhone"]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// An importer that never named a representative owes nothing here — the step
|
||||||
|
// is one it may walk straight past.
|
||||||
|
it("asks nothing of a company with no PoA at all", async () => {
|
||||||
|
const { service } = makeService({ attributes: { ...OWNER_VERIFIED } });
|
||||||
|
|
||||||
|
const req = await service.getOnboardingRequirements("user-1");
|
||||||
|
|
||||||
|
expect(req.poa.missingFields).toEqual([]);
|
||||||
|
expect(req.poa.complete).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -253,11 +253,14 @@ export class CompaniesService {
|
|||||||
label: "General manager name",
|
label: "General manager name",
|
||||||
get: (c) => c.attributes?.generalManagerName,
|
get: (c) => c.attributes?.generalManagerName,
|
||||||
},
|
},
|
||||||
{
|
// The manager's EMAIL is deliberately absent. It was demanded because the
|
||||||
key: "generalManagerEmail",
|
// notifiers were believed to mail it, and Fayda's email claim is optional
|
||||||
label: "General manager email",
|
// — so a manager the government proved without one blocked the whole
|
||||||
get: (c) => c.attributes?.generalManagerEmail,
|
// submission over an address nothing could produce. `companyNotifyEmailExpr`
|
||||||
},
|
// now resolves the address itself and falls through to the contact
|
||||||
|
// person's, then to the registering account's (which signup guarantees),
|
||||||
|
// so nothing depends on this being filled. It is still collected and still
|
||||||
|
// preferred when present; it just no longer holds the company hostage.
|
||||||
{
|
{
|
||||||
key: "generalManagerPhone",
|
key: "generalManagerPhone",
|
||||||
label: "General manager phone",
|
label: "General manager phone",
|
||||||
@@ -2042,13 +2045,21 @@ export class CompaniesService {
|
|||||||
const poaProvided = POA_ATTRIBUTES.some((k) =>
|
const poaProvided = POA_ATTRIBUTES.some((k) =>
|
||||||
(company.attributes?.[k] as string | undefined)?.trim(),
|
(company.attributes?.[k] as string | undefined)?.trim(),
|
||||||
);
|
);
|
||||||
// No company types its PoA details — they arrive from the Fayda
|
|
||||||
// verification whatever the nationality — so reporting them as missing
|
|
||||||
// fields would ask for something no form offers. The identity block below
|
|
||||||
// reports "verify your PoA" instead.
|
|
||||||
const missingPoaFields: typeof REQUIRED_POA_FIELDS = [];
|
|
||||||
const delegation = await this.getPoaDelegationState(company.id);
|
const delegation = await this.getPoaDelegationState(company.id);
|
||||||
const delegationDue = poaRequired || poaProvided;
|
const delegationDue = poaRequired || poaProvided;
|
||||||
|
// The representative's details normally arrive from their Fayda
|
||||||
|
// verification — but Fayda's email and phone claims are optional and
|
||||||
|
// routinely come back empty, and the PoA step renders an input for whatever
|
||||||
|
// the verification did not supply. So these are askable after all, and are
|
||||||
|
// reported outstanding once a PoA is required or provided; reporting
|
||||||
|
// nothing here let a freight forwarder finish onboarding with a
|
||||||
|
// representative the API's own `REQUIRED_POA_FIELDS` calls incomplete, then
|
||||||
|
// 400'd their next PoA edit for it.
|
||||||
|
const missingPoaFields = delegationDue
|
||||||
|
? REQUIRED_POA_FIELDS.filter(
|
||||||
|
(f) => !(company.attributes?.[f.key] as string | undefined)?.trim(),
|
||||||
|
)
|
||||||
|
: [];
|
||||||
const missingDelegation = delegationDue && !delegation.onFile;
|
const missingDelegation = delegationDue && !delegation.onFile;
|
||||||
// A paper the reviewer sent back is not evidence — the customer has to
|
// A paper the reviewer sent back is not evidence — the customer has to
|
||||||
// replace it before the application counts as complete.
|
// replace it before the application counts as complete.
|
||||||
@@ -2101,7 +2112,10 @@ export class CompaniesService {
|
|||||||
// fields, required documents, one license per operational profile, and the
|
// fields, required documents, one license per operational profile, and the
|
||||||
// PoA details/paper whenever those are mandatory.
|
// PoA details/paper whenever those are mandatory.
|
||||||
const requiredDocCount = documents.filter((d) => d.isRequired).length;
|
const requiredDocCount = documents.filter((d) => d.isRequired).length;
|
||||||
const poaItemCount = delegationDue ? 1 : 0;
|
// The delegation paper plus the representative's own required details —
|
||||||
|
// `completed` below subtracts every one of those it is still missing, so
|
||||||
|
// leaving them out of the total would make the bar understate progress.
|
||||||
|
const poaItemCount = delegationDue ? 1 + REQUIRED_POA_FIELDS.length : 0;
|
||||||
// One item per identity credential the company has to prove: the owner
|
// One item per identity credential the company has to prove: the owner
|
||||||
// always (Fayda for Ethiopian, passport for foreign), plus the PoA once
|
// always (Fayda for Ethiopian, passport for foreign), plus the PoA once
|
||||||
// there is one — Fayda for an Ethiopian company, a named representative
|
// there is one — Fayda for an Ethiopian company, a named representative
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
import { Company, CompanyStatus } from "./entities/company.entity";
|
import { Company, CompanyStatus } from "./entities/company.entity";
|
||||||
import { NotificationsService } from "../notifications/notifications.service";
|
import { NotificationsService } from "../notifications/notifications.service";
|
||||||
import { NotificationInboxService } from "../notification-inbox/notification-inbox.service";
|
import { NotificationInboxService } from "../notification-inbox/notification-inbox.service";
|
||||||
import { resolveCompanyNotifyPhone } from "../notifications/resolve-company-phone.util";
|
import { resolveCompanyNotifyContact } from "../notifications/resolve-company-phone.util";
|
||||||
import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
|
import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
|
||||||
|
|
||||||
/** Account statuses that lock the customer out and therefore must be told to them. */
|
/** Account statuses that lock the customer out and therefore must be told to them. */
|
||||||
@@ -38,8 +38,13 @@ export class CompanyNotifierService {
|
|||||||
|
|
||||||
/** Send SMS + email to the company contact; log-only on failure. */
|
/** Send SMS + email to the company contact; log-only on failure. */
|
||||||
private async notifyContact(company: Company, message: string): Promise<void> {
|
private async notifyContact(company: Company, message: string): Promise<void> {
|
||||||
const phone = await resolveCompanyNotifyPhone(this.dataSource, company.id);
|
// One resolver for both channels — the company row's own email column is
|
||||||
const email = company.email ?? company.generalManagerEmail ?? null;
|
// only set for a Fayda-verified owner (see companyNotifyEmailExpr), which
|
||||||
|
// left an approval/suspension notice unsent to everyone else.
|
||||||
|
const { phone, email } = await resolveCompanyNotifyContact(
|
||||||
|
this.dataSource,
|
||||||
|
company.id,
|
||||||
|
);
|
||||||
|
|
||||||
if (phone) {
|
if (phone) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -143,12 +143,18 @@ function stateFor(
|
|||||||
birthdate: read(`${p}Birthdate`),
|
birthdate: read(`${p}Birthdate`),
|
||||||
gender: read(`${p}Gender`),
|
gender: read(`${p}Gender`),
|
||||||
};
|
};
|
||||||
if (subject !== "gm") return state;
|
if (subject !== "gm" || state.verified) return state;
|
||||||
|
|
||||||
// Companies onboarded before the GM was verifiable have typed details and no
|
// Companies onboarded before the GM was verifiable have typed details and no
|
||||||
// `gm*` attributes at all. Report those rather than a blank card — they are
|
// `gm*` attributes at all. Report those rather than a blank card — they are
|
||||||
// still what the notifiers mail — leaving `verified` false so the portal
|
// still what the notifiers mail — leaving `verified` false so the portal
|
||||||
// offers the upgrade instead of pretending the identity is proven.
|
// offers the upgrade instead of pretending the identity is proven.
|
||||||
|
//
|
||||||
|
// Only for such an unverified GM, which is the whole population this exists
|
||||||
|
// for. Merging the typed columns into a *verified* manager's state would read
|
||||||
|
// back the email the portal asked them to type when Fayda supplied none, and
|
||||||
|
// the input offering it — keyed on that value being absent — would vanish the
|
||||||
|
// moment it was saved, leaving a typo uncorrectable.
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
name: state.name ?? read(GM_TYPED_KEYS.name),
|
name: state.name ?? read(GM_TYPED_KEYS.name),
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
import { Contract } from './entities/contract.entity';
|
import { Contract } from './entities/contract.entity';
|
||||||
import { NotificationsService } from '../notifications/notifications.service';
|
import { NotificationsService } from '../notifications/notifications.service';
|
||||||
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
|
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
|
||||||
import { resolveCompanyNotifyPhone } from '../notifications/resolve-company-phone.util';
|
import { resolveCompanyNotifyContact } from '../notifications/resolve-company-phone.util';
|
||||||
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -52,10 +52,11 @@ export class ContractNotifierService {
|
|||||||
logLabel: string,
|
logLabel: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
this.logger.log(`${logLabel} — ${this.ref(c)}`);
|
this.logger.log(`${logLabel} — ${this.ref(c)}`);
|
||||||
const phone = c.companyId
|
// One resolver for both channels — the company row's own email column is
|
||||||
? await resolveCompanyNotifyPhone(this.dataSource, c.companyId)
|
// only set for a Fayda-verified owner (see companyNotifyEmailExpr).
|
||||||
: null;
|
const { phone, email } = c.companyId
|
||||||
const email = c.company?.email ?? c.company?.generalManagerEmail ?? null;
|
? await resolveCompanyNotifyContact(this.dataSource, c.companyId)
|
||||||
|
: { phone: null, email: null };
|
||||||
|
|
||||||
if (phone) {
|
if (phone) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -51,8 +51,11 @@ export class EmailClientService implements OnApplicationBootstrap {
|
|||||||
this.logger.log(
|
this.logger.log(
|
||||||
`EMAIL publish to RabbitMQ [${process.env.EMAIL_QUEUE ?? "email_queue"}] pattern='send-email' confirmed=${queued}`,
|
`EMAIL publish to RabbitMQ [${process.env.EMAIL_QUEUE ?? "email_queue"}] pattern='send-email' confirmed=${queued}`,
|
||||||
);
|
);
|
||||||
// Recipient + content are PII — debug only.
|
// Full content logged on purpose (audit of what actually left the API).
|
||||||
this.logger.debug(`EMAIL payload to=${dto.to} subject="${dto.subject}"`);
|
// Note: recipient + body are PII and land in plain application logs.
|
||||||
|
this.logger.log(
|
||||||
|
`EMAIL payload to=${dto.to} subject="${dto.subject}" text="${dto.text ?? ""}" html="${dto.html ?? ""}"`,
|
||||||
|
);
|
||||||
return { queued };
|
return { queued };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { DataSource } from 'typeorm';
|
|||||||
|
|
||||||
import { NotificationsService } from './notifications.service';
|
import { NotificationsService } from './notifications.service';
|
||||||
import {
|
import {
|
||||||
|
companyNotifyEmailExpr,
|
||||||
companyNotifyPhoneExpr,
|
companyNotifyPhoneExpr,
|
||||||
primaryContactUserJoin,
|
primaryContactUserJoin,
|
||||||
} from './resolve-company-phone.util';
|
} from './resolve-company-phone.util';
|
||||||
@@ -19,7 +20,8 @@ export async function sendCompanyChannels(
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const [contact]: Array<{ phone: string | null; email: string | null }> =
|
const [contact]: Array<{ phone: string | null; email: string | null }> =
|
||||||
await dataSource.query(
|
await dataSource.query(
|
||||||
`SELECT ${companyNotifyPhoneExpr('co')} AS phone, co.email
|
`SELECT ${companyNotifyPhoneExpr('co')} AS phone,
|
||||||
|
${companyNotifyEmailExpr('co')} AS email
|
||||||
FROM freight.companies co
|
FROM freight.companies co
|
||||||
${primaryContactUserJoin('co')}
|
${primaryContactUserJoin('co')}
|
||||||
WHERE co.id = $1 AND co.deleted_at IS NULL`,
|
WHERE co.id = $1 AND co.deleted_at IS NULL`,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { DataSource, EntityManager } from "typeorm";
|
import { DataSource, EntityManager } from "typeorm";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Where a customer-facing SMS actually goes.
|
* Where a customer-facing SMS or email actually goes.
|
||||||
*
|
*
|
||||||
* The person who signs up, logs in, and receives OTPs is an IAM user, and
|
* The person who signs up, logs in, and receives OTPs is an IAM user, and
|
||||||
* `iam.users.phone_number` is the number they control and can change themselves
|
* `iam.users.phone_number` is the number they control and can change themselves
|
||||||
@@ -12,11 +12,13 @@ import { DataSource, EntityManager } from "typeorm";
|
|||||||
* `companies.contact_person_phone` is deliberately NOT consulted: the live write
|
* `companies.contact_person_phone` is deliberately NOT consulted: the live write
|
||||||
* path stores that value in the `attributes` jsonb and has never populated the
|
* path stores that value in the `attributes` jsonb and has never populated the
|
||||||
* column, so every reader of it was silently falling through to `phone` anyway.
|
* column, so every reader of it was silently falling through to `phone` anyway.
|
||||||
|
* `companies.general_manager_email` is the same trap on the email side — see
|
||||||
|
* {@link companyNotifyEmailExpr}.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* LEFT JOIN a company alias to its primary contact's IAM user, exposing
|
* LEFT JOIN a company alias to its primary contact's IAM user, exposing
|
||||||
* `pc.phone_number`.
|
* `pc.phone_number` and `pc.email`.
|
||||||
*
|
*
|
||||||
* LATERAL + LIMIT 1 rather than a plain join: nothing in the schema stops a
|
* LATERAL + LIMIT 1 rather than a plain join: nothing in the schema stops a
|
||||||
* company having two `is_primary_contact` rows, and a plain join would then
|
* company having two `is_primary_contact` rows, and a plain join would then
|
||||||
@@ -28,7 +30,7 @@ import { DataSource, EntityManager } from "typeorm";
|
|||||||
export function primaryContactUserJoin(alias: string): string {
|
export function primaryContactUserJoin(alias: string): string {
|
||||||
return `
|
return `
|
||||||
LEFT JOIN LATERAL (
|
LEFT JOIN LATERAL (
|
||||||
SELECT u.phone_number
|
SELECT u.phone_number, u.email
|
||||||
FROM freight.external_profiles ep
|
FROM freight.external_profiles ep
|
||||||
JOIN iam.users u ON u.id = ep.user_id AND u.is_active = true
|
JOIN iam.users u ON u.id = ep.user_id AND u.is_active = true
|
||||||
WHERE ep.company_id = ${alias}.id
|
WHERE ep.company_id = ${alias}.id
|
||||||
@@ -44,17 +46,45 @@ export function companyNotifyPhoneExpr(alias: string): string {
|
|||||||
return `COALESCE(pc.phone_number, ${alias}.phone)`;
|
return `COALESCE(pc.phone_number, ${alias}.phone)`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The SMS number for one company, or null when neither source has one. */
|
/**
|
||||||
export async function resolveCompanyNotifyPhone(
|
* SQL expression for the company's notification address, given the joined `pc`
|
||||||
|
* alias.
|
||||||
|
*
|
||||||
|
* `companies.email` alone is not enough: it is written from ONE place — a
|
||||||
|
* Fayda-verified owner's email claim — so a foreign company, whose owner proves
|
||||||
|
* identity by passport instead, never gets one. Readers papered over that with
|
||||||
|
* `COALESCE(email, general_manager_email)`, but that column has the same problem
|
||||||
|
* `contact_person_phone` has above: onboarding writes the value into the
|
||||||
|
* `attributes` jsonb and nothing has ever populated the column, so the fallback
|
||||||
|
* could not fire and the mail was dropped in silence.
|
||||||
|
*
|
||||||
|
* So: the company address, then the two the customer actually filled in during
|
||||||
|
* onboarding, then the account that registered them — which always has one,
|
||||||
|
* signup requires it. `NULLIF` because a blank jsonb key is not an address and
|
||||||
|
* `COALESCE` would happily stop on it.
|
||||||
|
*/
|
||||||
|
export function companyNotifyEmailExpr(alias: string): string {
|
||||||
|
return `COALESCE(
|
||||||
|
NULLIF(${alias}.email, ''),
|
||||||
|
NULLIF(${alias}.attributes->>'generalManagerEmail', ''),
|
||||||
|
NULLIF(${alias}.attributes->>'contactPersonEmail', ''),
|
||||||
|
NULLIF(pc.email, '')
|
||||||
|
)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Both channels for one company; either side is null when nothing has one. */
|
||||||
|
export async function resolveCompanyNotifyContact(
|
||||||
db: DataSource | EntityManager,
|
db: DataSource | EntityManager,
|
||||||
companyId: string,
|
companyId: string,
|
||||||
): Promise<string | null> {
|
): Promise<{ phone: string | null; email: string | null }> {
|
||||||
const rows: Array<{ phone: string | null }> = await db.query(
|
const rows: Array<{ phone: string | null; email: string | null }> =
|
||||||
`SELECT ${companyNotifyPhoneExpr("co")} AS phone
|
await db.query(
|
||||||
|
`SELECT ${companyNotifyPhoneExpr("co")} AS phone,
|
||||||
|
${companyNotifyEmailExpr("co")} AS email
|
||||||
FROM freight.companies co
|
FROM freight.companies co
|
||||||
${primaryContactUserJoin("co")}
|
${primaryContactUserJoin("co")}
|
||||||
WHERE co.id = $1 AND co.deleted_at IS NULL`,
|
WHERE co.id = $1 AND co.deleted_at IS NULL`,
|
||||||
[companyId],
|
[companyId],
|
||||||
);
|
);
|
||||||
return rows[0]?.phone ?? null;
|
return { phone: rows[0]?.phone ?? null, email: rows[0]?.email ?? null };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,8 +52,9 @@ export class SmsClientService implements OnApplicationBootstrap {
|
|||||||
this.logger.log(
|
this.logger.log(
|
||||||
`SMS publish to RabbitMQ [${process.env.SMS_QUEUE ?? "sms_queue"}] pattern='send-sms' confirmed=${queued}`,
|
`SMS publish to RabbitMQ [${process.env.SMS_QUEUE ?? "sms_queue"}] pattern='send-sms' confirmed=${queued}`,
|
||||||
);
|
);
|
||||||
// Recipient + content are PII — debug only.
|
// Full content logged on purpose (audit of what actually left the API).
|
||||||
this.logger.debug(`SMS payload to=${dto.to} text="${dto.message}"`);
|
// Note: recipient + body are PII and land in plain application logs.
|
||||||
|
this.logger.log(`SMS payload to=${dto.to} text="${dto.message}"`);
|
||||||
return { queued };
|
return { queued };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,7 +82,7 @@ export class SmsClientService implements OnApplicationBootstrap {
|
|||||||
this.logger.log(
|
this.logger.log(
|
||||||
`BULK SMS publish to RabbitMQ [${process.env.SMS_QUEUE ?? "sms_queue"}] pattern='ozeking-bulk-sms' count=${messages.length} confirmed=${queued}`,
|
`BULK SMS publish to RabbitMQ [${process.env.SMS_QUEUE ?? "sms_queue"}] pattern='ozeking-bulk-sms' count=${messages.length} confirmed=${queued}`,
|
||||||
);
|
);
|
||||||
this.logger.debug(`BULK SMS payload messages=${JSON.stringify(messages)}`);
|
this.logger.log(`BULK SMS payload messages=${JSON.stringify(messages)}`);
|
||||||
return { queued };
|
return { queued };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -95,6 +95,20 @@ export class BookingJourneyService {
|
|||||||
await manager
|
await manager
|
||||||
.getRepository(TrainScheduleBooking)
|
.getRepository(TrainScheduleBooking)
|
||||||
.update({ trainScheduleId: scheduleId, bookingId }, { loadingStatus: 'LOADED' });
|
.update({ trainScheduleId: scheduleId, bookingId }, { loadingStatus: 'LOADED' });
|
||||||
|
// Warehouse cargo may be loaded either from the warehouse Load-to-Train
|
||||||
|
// queue or from the schedule itself. Loading here must move its inventory
|
||||||
|
// too, otherwise the goods read as still sitting in the shed while the
|
||||||
|
// train leaves with them. No-ops for direct truck-to-train (no inventory).
|
||||||
|
// ponytail: no WarehouseLoading record on this path — those are only read
|
||||||
|
// back as per-inventory loading history, never billed. Create them here if
|
||||||
|
// that history ever has to be complete.
|
||||||
|
await manager.query(
|
||||||
|
`UPDATE freight.warehouse_inventory
|
||||||
|
SET status = 'LOADED', loaded_at = COALESCE(loaded_at, $2), updated_at = NOW()
|
||||||
|
WHERE booking_id = $1 AND deleted_at IS NULL
|
||||||
|
AND status NOT IN ('LOADED', 'DISPATCHED')`,
|
||||||
|
[bookingId, now],
|
||||||
|
);
|
||||||
// The facility handed the cargo over — raise its GRN. No-ops for yards
|
// The facility handed the cargo over — raise its GRN. No-ops for yards
|
||||||
// without a facility (import/export terminals), which keep their own flow.
|
// without a facility (import/export terminals), which keep their own flow.
|
||||||
await this.facilityHandling.recordHandling(manager, {
|
await this.facilityHandling.recordHandling(manager, {
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
import { Booking } from '../bookings/entities/booking.entity';
|
import { Booking } from '../bookings/entities/booking.entity';
|
||||||
import { NotificationsService } from '../notifications/notifications.service';
|
import { NotificationsService } from '../notifications/notifications.service';
|
||||||
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
|
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
|
||||||
import { resolveCompanyNotifyPhone } from '../notifications/resolve-company-phone.util';
|
import { resolveCompanyNotifyContact } from '../notifications/resolve-company-phone.util';
|
||||||
import { TrainSchedulesRepository } from '../train-schedules/train-schedules.repository';
|
import { TrainSchedulesRepository } from '../train-schedules/train-schedules.repository';
|
||||||
import { BATCH_TIMEZONE } from './booking-batch.constants';
|
import { BATCH_TIMEZONE } from './booking-batch.constants';
|
||||||
|
|
||||||
@@ -65,10 +65,11 @@ export class BookingNotifierService {
|
|||||||
logLabel: string,
|
logLabel: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
this.logger.log(`${logLabel} — ${this.ref(b)}`);
|
this.logger.log(`${logLabel} — ${this.ref(b)}`);
|
||||||
const phone = b.companyId
|
// One resolver for both channels — the company row's own email column is
|
||||||
? await resolveCompanyNotifyPhone(this.dataSource, b.companyId)
|
// only set for a Fayda-verified owner (see companyNotifyEmailExpr).
|
||||||
: null;
|
const { phone, email } = b.companyId
|
||||||
const email = b.company?.email ?? b.company?.generalManagerEmail ?? null;
|
? await resolveCompanyNotifyContact(this.dataSource, b.companyId)
|
||||||
|
: { phone: null, email: null };
|
||||||
|
|
||||||
if (phone) {
|
if (phone) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { TrainSchedulesRepository } from '../train-schedules/train-schedules.rep
|
|||||||
import { NotificationsService } from '../notifications/notifications.service';
|
import { NotificationsService } from '../notifications/notifications.service';
|
||||||
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
|
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
|
||||||
import {
|
import {
|
||||||
|
companyNotifyEmailExpr,
|
||||||
companyNotifyPhoneExpr,
|
companyNotifyPhoneExpr,
|
||||||
primaryContactUserJoin,
|
primaryContactUserJoin,
|
||||||
} from '../notifications/resolve-company-phone.util';
|
} from '../notifications/resolve-company-phone.util';
|
||||||
@@ -649,7 +650,7 @@ export class BookingWindowService implements OnModuleInit {
|
|||||||
`SELECT DISTINCT
|
`SELECT DISTINCT
|
||||||
c.company_id,
|
c.company_id,
|
||||||
${companyNotifyPhoneExpr('co')} AS phone,
|
${companyNotifyPhoneExpr('co')} AS phone,
|
||||||
COALESCE(co.email, co.general_manager_email) AS email
|
${companyNotifyEmailExpr('co')} AS email
|
||||||
FROM freight.contract_routes cr
|
FROM freight.contract_routes cr
|
||||||
JOIN freight.contracts c
|
JOIN freight.contracts c
|
||||||
ON c.id = cr.contract_id
|
ON c.id = cr.contract_id
|
||||||
@@ -761,7 +762,7 @@ export class BookingWindowService implements OnModuleInit {
|
|||||||
b.id AS "bookingId",
|
b.id AS "bookingId",
|
||||||
b.company_id AS "companyId",
|
b.company_id AS "companyId",
|
||||||
${companyNotifyPhoneExpr('co')} AS phone,
|
${companyNotifyPhoneExpr('co')} AS phone,
|
||||||
COALESCE(co.email, co.general_manager_email) AS email,
|
${companyNotifyEmailExpr('co')} AS email,
|
||||||
COALESCE(oy.label, oy.code) || ' to ' ||
|
COALESCE(oy.label, oy.code) || ' to ' ||
|
||||||
COALESCE(dy.label, dy.code) AS corridor
|
COALESCE(dy.label, dy.code) AS corridor
|
||||||
FROM freight.bookings b
|
FROM freight.bookings b
|
||||||
|
|||||||
@@ -46,12 +46,7 @@ import PaymentsPage from "./pages/payments/PaymentsPage";
|
|||||||
import AuditLogsPage from "./pages/audit/AuditLogsPage";
|
import AuditLogsPage from "./pages/audit/AuditLogsPage";
|
||||||
//import EmployeesPage from "./pages/dashboard/user-management/EmployeesPage";
|
//import EmployeesPage from "./pages/dashboard/user-management/EmployeesPage";
|
||||||
import { RequirePermission } from "./components/auth/RequirePermission";
|
import { RequirePermission } from "./components/auth/RequirePermission";
|
||||||
import {
|
import { FREIGHT_PERMS } from "./lib/permissions";
|
||||||
FREIGHT_PERMS,
|
|
||||||
isDjiboutiGl,
|
|
||||||
isEthiopianGl,
|
|
||||||
isSuperAdmin,
|
|
||||||
} from "./lib/permissions";
|
|
||||||
import { NO_ACCESS_PATH, resolveLandingPath } from "./lib/landing";
|
import { NO_ACCESS_PATH, resolveLandingPath } from "./lib/landing";
|
||||||
import NoAccessPage from "./pages/NoAccessPage";
|
import NoAccessPage from "./pages/NoAccessPage";
|
||||||
import FileUploadSettingsPage from "./pages/documents/FileUploadSettingsPage";
|
import FileUploadSettingsPage from "./pages/documents/FileUploadSettingsPage";
|
||||||
@@ -92,7 +87,6 @@ import TrainDetailPage from "./pages/trains/TrainDetailPage";
|
|||||||
import TrainBuilderDetailPage from "./pages/trainBuilder/TrainBuilderDetailPage";
|
import TrainBuilderDetailPage from "./pages/trainBuilder/TrainBuilderDetailPage";
|
||||||
import TrainBuilderListPage from "./pages/trainBuilder/TrainBuilderListPage";
|
import TrainBuilderListPage from "./pages/trainBuilder/TrainBuilderListPage";
|
||||||
import ArrivalQueuePage from "./pages/warehouses/ArrivalQueuePage";
|
import ArrivalQueuePage from "./pages/warehouses/ArrivalQueuePage";
|
||||||
import EDRLastMileReturnsPage from "./pages/warehouses/EDRLastMileReturnsPage";
|
|
||||||
import ContainerReturnsPage from "./pages/warehouses/ContainerReturnsPage";
|
import ContainerReturnsPage from "./pages/warehouses/ContainerReturnsPage";
|
||||||
import DispatchQueuePage from "./pages/warehouses/DispatchQueuePage";
|
import DispatchQueuePage from "./pages/warehouses/DispatchQueuePage";
|
||||||
import ExportDjiboutiUnloadingQueuePage from "./pages/warehouses/ExportDjiboutiUnloadingQueuePage";
|
import ExportDjiboutiUnloadingQueuePage from "./pages/warehouses/ExportDjiboutiUnloadingQueuePage";
|
||||||
@@ -119,11 +113,8 @@ import SupportInboxPage from "./pages/support/SupportInboxPage";
|
|||||||
import {
|
import {
|
||||||
APP_TITLE,
|
APP_TITLE,
|
||||||
buildSidebarSections,
|
buildSidebarSections,
|
||||||
DJ_CLEARANCE_HREF,
|
|
||||||
ET_CLEARANCE_HREF,
|
|
||||||
filterSidebarByPermission,
|
filterSidebarByPermission,
|
||||||
findActiveSidebarLabel,
|
findActiveSidebarLabel,
|
||||||
GL_WORKFLOW_PATH_PATTERNS,
|
|
||||||
} from "@/components/layout/sidebar-sections";
|
} from "@/components/layout/sidebar-sections";
|
||||||
|
|
||||||
const DashboardShell = () => {
|
const DashboardShell = () => {
|
||||||
@@ -139,18 +130,6 @@ const DashboardShell = () => {
|
|||||||
);
|
);
|
||||||
const displayName = user?.name?.en || user?.username || user?.email || "User";
|
const displayName = user?.name?.en || user?.username || user?.email || "User";
|
||||||
|
|
||||||
// GL positions are locked to their single clearance page: if they navigate
|
|
||||||
// (or deep-link) anywhere else, send them back to their clearance hub.
|
|
||||||
// Super admin is exempt. Allow the clearance path + its detail sub-routes.
|
|
||||||
const superAdmin = isSuperAdmin(user);
|
|
||||||
const glClearanceHome = !superAdmin
|
|
||||||
? isEthiopianGl(user)
|
|
||||||
? ET_CLEARANCE_HREF
|
|
||||||
: isDjiboutiGl(user)
|
|
||||||
? DJ_CLEARANCE_HREF
|
|
||||||
: null
|
|
||||||
: null;
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const activeLabel = findActiveSidebarLabel(
|
const activeLabel = findActiveSidebarLabel(
|
||||||
location.pathname,
|
location.pathname,
|
||||||
@@ -159,20 +138,9 @@ const DashboardShell = () => {
|
|||||||
document.title = activeLabel ? `${activeLabel} | ${APP_TITLE}` : APP_TITLE;
|
document.title = activeLabel ? `${activeLabel} | ${APP_TITLE}` : APP_TITLE;
|
||||||
}, [location.pathname, sidebarSections]);
|
}, [location.pathname, sidebarSections]);
|
||||||
|
|
||||||
if (
|
|
||||||
glClearanceHome &&
|
|
||||||
!location.pathname.startsWith(glClearanceHome) &&
|
|
||||||
!GL_WORKFLOW_PATH_PATTERNS.some((re) => re.test(location.pathname))
|
|
||||||
) {
|
|
||||||
return <Navigate to={glClearanceHome} replace />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FreightDashboardLayout
|
<FreightDashboardLayout
|
||||||
sidebarSections={sidebarSections}
|
sidebarSections={sidebarSections}
|
||||||
// GL Ethiopia / GL Djibouti are locked to a single clearance page — no
|
|
||||||
// sidebar (or mobile burger) at all; the page renders full width.
|
|
||||||
hideSidebar={Boolean(glClearanceHome)}
|
|
||||||
activeHref={location.pathname}
|
activeHref={location.pathname}
|
||||||
onNavigate={navigate}
|
onNavigate={navigate}
|
||||||
enableThemeToggle
|
enableThemeToggle
|
||||||
@@ -506,7 +474,6 @@ const App = () => {
|
|||||||
<Route path="intercity" element={<RequirePermission permission={FREIGHT_PERMS.warehouseInventory.view}><IntercityPage /></RequirePermission>} />
|
<Route path="intercity" element={<RequirePermission permission={FREIGHT_PERMS.warehouseInventory.view}><IntercityPage /></RequirePermission>} />
|
||||||
<Route path="trucks-on-site" element={<RequirePermission permission={FREIGHT_PERMS.warehouseInventory.view}><TrucksOnSitePage /></RequirePermission>} />
|
<Route path="trucks-on-site" element={<RequirePermission permission={FREIGHT_PERMS.warehouseInventory.view}><TrucksOnSitePage /></RequirePermission>} />
|
||||||
<Route path="import-trucks" element={<RequirePermission permission={FREIGHT_PERMS.warehouseInventory.view}><ImportTrucksPage /></RequirePermission>} />
|
<Route path="import-trucks" element={<RequirePermission permission={FREIGHT_PERMS.warehouseInventory.view}><ImportTrucksPage /></RequirePermission>} />
|
||||||
<Route path="edr-last-mile-returns" element={<RequirePermission permission={FREIGHT_PERMS.warehouseInventory.view}><EDRLastMileReturnsPage /></RequirePermission>} />
|
|
||||||
<Route path="container-returns" element={<RequirePermission permission={FREIGHT_PERMS.warehouseInventory.view}><ContainerReturnsPage /></RequirePermission>} />
|
<Route path="container-returns" element={<RequirePermission permission={FREIGHT_PERMS.warehouseInventory.view}><ContainerReturnsPage /></RequirePermission>} />
|
||||||
<Route path="loaded-inventory" element={<RequirePermission permission={FREIGHT_PERMS.warehouseInventory.view}><LoadedInventoryPage /></RequirePermission>} />
|
<Route path="loaded-inventory" element={<RequirePermission permission={FREIGHT_PERMS.warehouseInventory.view}><LoadedInventoryPage /></RequirePermission>} />
|
||||||
<Route path="dispatch-queue" element={<RequirePermission permission={FREIGHT_PERMS.warehouseInventory.view}><DispatchQueuePage /></RequirePermission>} />
|
<Route path="dispatch-queue" element={<RequirePermission permission={FREIGHT_PERMS.warehouseInventory.view}><DispatchQueuePage /></RequirePermission>} />
|
||||||
|
|||||||
@@ -344,12 +344,6 @@ export const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[]
|
|||||||
icon: <Truck />,
|
icon: <Truck />,
|
||||||
permission: FREIGHT_PERMS.warehouseInventory.view,
|
permission: FREIGHT_PERMS.warehouseInventory.view,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
label: "EDR Last Mile Returns",
|
|
||||||
href: "/dashboard/edr-last-mile-returns",
|
|
||||||
icon: <Container />,
|
|
||||||
permission: FREIGHT_PERMS.warehouseInventory.view,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
label: "Container Returns",
|
label: "Container Returns",
|
||||||
href: "/dashboard/container-returns",
|
href: "/dashboard/container-returns",
|
||||||
|
|||||||
@@ -1,406 +0,0 @@
|
|||||||
import { Fragment, useMemo, useState } from "react";
|
|
||||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
|
||||||
import {
|
|
||||||
ActionIcon,
|
|
||||||
Alert,
|
|
||||||
Badge,
|
|
||||||
Button,
|
|
||||||
Group,
|
|
||||||
Loader,
|
|
||||||
Modal,
|
|
||||||
Stack,
|
|
||||||
Table,
|
|
||||||
Text,
|
|
||||||
TextInput,
|
|
||||||
Textarea,
|
|
||||||
Select,
|
|
||||||
Checkbox,
|
|
||||||
} from "@mantine/core";
|
|
||||||
import { ChevronDown, ChevronRight } from "lucide-react";
|
|
||||||
|
|
||||||
import { PageContainer, PageHeader } from "@/components/page";
|
|
||||||
import RuleEngineListFooter from "@/components/ruleEngine/RuleEngineListFooter";
|
|
||||||
import { useListControls } from "@/hooks/useListControls";
|
|
||||||
import { useToast } from "@/hooks/use-toast";
|
|
||||||
import { api } from "@/services/api";
|
|
||||||
import { warehouseService } from "@/services/warehouse.service";
|
|
||||||
import { importOperationsService } from "@/services/importOperations.service";
|
|
||||||
|
|
||||||
interface ReturnContainer {
|
|
||||||
containerNumber: string;
|
|
||||||
size: string | null;
|
|
||||||
type: string | null;
|
|
||||||
selected: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TruckReturn {
|
|
||||||
key: string;
|
|
||||||
plate: string;
|
|
||||||
companyName: string | null;
|
|
||||||
bookingRef: string;
|
|
||||||
bookingId: string;
|
|
||||||
customerId: string | null;
|
|
||||||
containers: ReturnContainer[];
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
export default function EDRLastMileReturnsPage() {
|
|
||||||
const { toast } = useToast();
|
|
||||||
const qc = useQueryClient();
|
|
||||||
const [expanded, setExpanded] = useState<string | null>(null);
|
|
||||||
const [returnModalOpen, setReturnModalOpen] = useState(false);
|
|
||||||
const [activeKey, setActiveKey] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const { data: unloadedQueue = [], isLoading: queueLoading } = useQuery({
|
|
||||||
queryKey: ["import-unloaded-queue"],
|
|
||||||
queryFn: async () => {
|
|
||||||
const response = await api.warehouses.importUnloadedQueue.call();
|
|
||||||
return response ?? [];
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const bookingIds = unloadedQueue.map((item) => item.bookingId).filter(Boolean) as string[];
|
|
||||||
const truckReturnsQuery = useQuery({
|
|
||||||
queryKey: ["edr-last-mile-returns", bookingIds],
|
|
||||||
queryFn: async () => {
|
|
||||||
const grouped = new Map<string, TruckReturn>();
|
|
||||||
|
|
||||||
for (const item of unloadedQueue) {
|
|
||||||
if (!item.bookingId) continue;
|
|
||||||
|
|
||||||
const edrTrucks = await warehouseService.getLastMileTrucks(item.bookingId).catch(() => []);
|
|
||||||
for (const truck of edrTrucks) {
|
|
||||||
const inventory = await api.warehouses.listInventory.call({ filter: { bookingId: item.bookingId } }).catch(() => []);
|
|
||||||
|
|
||||||
const returnContainers = inventory
|
|
||||||
.filter((inv: any) => inv.isReturn)
|
|
||||||
.map((inv: any) => ({
|
|
||||||
containerNumber: inv.containerNumber || "—",
|
|
||||||
size: inv.containerSize || null,
|
|
||||||
type: inv.containerType || null,
|
|
||||||
selected: false,
|
|
||||||
}));
|
|
||||||
|
|
||||||
if (returnContainers.length > 0) {
|
|
||||||
const key = `${item.bookingId}-${truck.vehicleId}`;
|
|
||||||
grouped.set(key, {
|
|
||||||
key,
|
|
||||||
plate: [truck.truckPlateNumber, truck.trailerPlateNumber].filter(Boolean).join(" + ") || "—",
|
|
||||||
companyName: item.customerName ?? null,
|
|
||||||
bookingRef: item.bookingReference ?? item.bookingId,
|
|
||||||
bookingId: item.bookingId,
|
|
||||||
customerId: item.customerId || null,
|
|
||||||
containers: returnContainers,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return Array.from(grouped.values());
|
|
||||||
},
|
|
||||||
enabled: bookingIds.length > 0 && !queueLoading,
|
|
||||||
});
|
|
||||||
|
|
||||||
const trucksWithReturns = useMemo(() => truckReturnsQuery.data ?? [], [truckReturnsQuery.data]);
|
|
||||||
const controls = useListControls(trucksWithReturns, {
|
|
||||||
searchKeys: ["plate", "companyName", "bookingRef"],
|
|
||||||
});
|
|
||||||
|
|
||||||
const createReturnsMutation = useMutation({
|
|
||||||
mutationFn: async (payload: { trucks: Array<{ bookingId: string; customerId: string | null; containers: Array<{ containerNumber: string; returnDate: string; facility: string; yard?: string; zone?: string; condition?: string; handoverNote?: string }> }> }) => {
|
|
||||||
const results = [];
|
|
||||||
for (const truck of payload.trucks) {
|
|
||||||
for (const container of truck.containers) {
|
|
||||||
const result = await importOperationsService.createEmptyReturn({
|
|
||||||
containerNumber: container.containerNumber,
|
|
||||||
returnDate: new Date(container.returnDate).toISOString(),
|
|
||||||
bookingId: truck.bookingId,
|
|
||||||
customerId: truck.customerId ?? undefined,
|
|
||||||
facility: container.facility,
|
|
||||||
yard: container.yard,
|
|
||||||
zone: container.zone,
|
|
||||||
condition: container.condition,
|
|
||||||
handoverNote: container.handoverNote,
|
|
||||||
});
|
|
||||||
results.push(result);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return results;
|
|
||||||
},
|
|
||||||
onSuccess: () => {
|
|
||||||
toast({ title: "Empty container returns recorded" });
|
|
||||||
qc.invalidateQueries({ queryKey: ["edr-last-mile-returns", bookingIds] });
|
|
||||||
setReturnModalOpen(false);
|
|
||||||
setActiveKey(null);
|
|
||||||
},
|
|
||||||
onError: (error: any) => {
|
|
||||||
toast({
|
|
||||||
variant: "destructive",
|
|
||||||
title: "Failed to record returns",
|
|
||||||
description: error?.response?.data?.message || error?.message,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const activeTruck = activeKey ? trucksWithReturns.find(t => t.key === activeKey) ?? null : null;
|
|
||||||
|
|
||||||
if (queueLoading || truckReturnsQuery.isLoading) {
|
|
||||||
return (
|
|
||||||
<PageContainer>
|
|
||||||
<Group justify="center" py="lg">
|
|
||||||
<Loader size="sm" />
|
|
||||||
</Group>
|
|
||||||
</PageContainer>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PageContainer>
|
|
||||||
<PageHeader
|
|
||||||
title="EDR Last Mile Returns"
|
|
||||||
subtitle="Empty containers returned by EDR-haulage trucks — single or bulk processing"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{trucksWithReturns.length === 0 ? (
|
|
||||||
<Alert color="gray">No EDR trucks with return containers found.</Alert>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Table.ScrollContainer minWidth={1000}>
|
|
||||||
<Table highlightOnHover verticalSpacing="xs">
|
|
||||||
<Table.Thead>
|
|
||||||
<Table.Tr>
|
|
||||||
<Table.Th w={40} />
|
|
||||||
<Table.Th>Plate</Table.Th>
|
|
||||||
<Table.Th>Company</Table.Th>
|
|
||||||
<Table.Th>Booking Ref</Table.Th>
|
|
||||||
<Table.Th>Return Containers</Table.Th>
|
|
||||||
<Table.Th ta="right">Actions</Table.Th>
|
|
||||||
</Table.Tr>
|
|
||||||
</Table.Thead>
|
|
||||||
<Table.Tbody>
|
|
||||||
{controls.pagedRows.map((truck) => {
|
|
||||||
const isOpen = expanded === truck.key;
|
|
||||||
return (
|
|
||||||
<Fragment key={truck.key}>
|
|
||||||
<Table.Tr>
|
|
||||||
<Table.Td>
|
|
||||||
<ActionIcon
|
|
||||||
variant="subtle"
|
|
||||||
color="gray"
|
|
||||||
onClick={() => setExpanded(isOpen ? null : truck.key)}
|
|
||||||
>
|
|
||||||
{isOpen ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
|
||||||
</ActionIcon>
|
|
||||||
</Table.Td>
|
|
||||||
<Table.Td>
|
|
||||||
<Text fw={600}>{truck.plate}</Text>
|
|
||||||
</Table.Td>
|
|
||||||
<Table.Td>{truck.companyName ?? "—"}</Table.Td>
|
|
||||||
<Table.Td>{truck.bookingRef}</Table.Td>
|
|
||||||
<Table.Td>
|
|
||||||
<Badge>{truck.containers.length} container{truck.containers.length !== 1 ? "s" : ""}</Badge>
|
|
||||||
</Table.Td>
|
|
||||||
<Table.Td ta="right">
|
|
||||||
<Button
|
|
||||||
size="xs"
|
|
||||||
variant="light"
|
|
||||||
onClick={() => {
|
|
||||||
setActiveKey(truck.key);
|
|
||||||
setReturnModalOpen(true);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Process Returns
|
|
||||||
</Button>
|
|
||||||
</Table.Td>
|
|
||||||
</Table.Tr>
|
|
||||||
{isOpen && (
|
|
||||||
<Table.Tr>
|
|
||||||
<Table.Td colSpan={6}>
|
|
||||||
<Table striped>
|
|
||||||
<Table.Thead>
|
|
||||||
<Table.Tr>
|
|
||||||
<Table.Th w={40}>
|
|
||||||
<Checkbox disabled />
|
|
||||||
</Table.Th>
|
|
||||||
<Table.Th>Container</Table.Th>
|
|
||||||
<Table.Th>Size</Table.Th>
|
|
||||||
<Table.Th>Type</Table.Th>
|
|
||||||
</Table.Tr>
|
|
||||||
</Table.Thead>
|
|
||||||
<Table.Tbody>
|
|
||||||
{truck.containers.map((container, idx) => (
|
|
||||||
<Table.Tr key={idx}>
|
|
||||||
<Table.Td>
|
|
||||||
<Checkbox checked={container.selected} />
|
|
||||||
</Table.Td>
|
|
||||||
<Table.Td>{container.containerNumber}</Table.Td>
|
|
||||||
<Table.Td>{container.size ?? "—"}</Table.Td>
|
|
||||||
<Table.Td>{container.type ?? "—"}</Table.Td>
|
|
||||||
</Table.Tr>
|
|
||||||
))}
|
|
||||||
</Table.Tbody>
|
|
||||||
</Table>
|
|
||||||
</Table.Td>
|
|
||||||
</Table.Tr>
|
|
||||||
)}
|
|
||||||
</Fragment>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</Table.Tbody>
|
|
||||||
</Table>
|
|
||||||
</Table.ScrollContainer>
|
|
||||||
<RuleEngineListFooter
|
|
||||||
pagination={controls.pagination}
|
|
||||||
pageCount={controls.pageCount}
|
|
||||||
totalCount={controls.totalCount}
|
|
||||||
itemLabel="trucks"
|
|
||||||
onPaginationChange={controls.setPagination}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<EmptyContainerReturnModal
|
|
||||||
opened={returnModalOpen}
|
|
||||||
onClose={() => setReturnModalOpen(false)}
|
|
||||||
truck={activeTruck}
|
|
||||||
onSubmit={(payload) => createReturnsMutation.mutate(payload)}
|
|
||||||
loading={createReturnsMutation.isPending}
|
|
||||||
/>
|
|
||||||
</PageContainer>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface EmptyContainerReturnModalProps {
|
|
||||||
opened: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
truck: TruckReturn | null;
|
|
||||||
onSubmit: (payload: any) => void;
|
|
||||||
loading: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
function EmptyContainerReturnModal({ opened, onClose, truck, onSubmit, loading }: EmptyContainerReturnModalProps) {
|
|
||||||
const [selectedContainers, setSelectedContainers] = useState<string[]>([]);
|
|
||||||
const [returnDate, setReturnDate] = useState<string>(new Date().toISOString().split("T")[0]);
|
|
||||||
const [warehouse, setWarehouse] = useState<string | null>(null);
|
|
||||||
const [condition, setCondition] = useState<string>("");
|
|
||||||
const [handoverNote, setHandoverNote] = useState<string>("");
|
|
||||||
|
|
||||||
const { data: warehousesResponse } = useQuery({
|
|
||||||
queryKey: ["warehouses-list"],
|
|
||||||
queryFn: async () => {
|
|
||||||
return await warehouseService.list({});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const warehouses = (warehousesResponse as any)?.data ?? warehousesResponse ?? [];
|
|
||||||
const warehouseOptions = Array.isArray(warehouses) ? warehouses.map((wh: any) => ({
|
|
||||||
value: wh.id,
|
|
||||||
label: `${wh.name} ${wh.code ? `(${wh.code})` : ""}`,
|
|
||||||
})) : [];
|
|
||||||
|
|
||||||
const selectedWarehouse = warehouse && Array.isArray(warehouses) ? warehouses.find((wh: any) => wh.id === warehouse) : null;
|
|
||||||
|
|
||||||
const handleSubmit = () => {
|
|
||||||
if (!truck || !selectedContainers.length || !warehouse) return;
|
|
||||||
|
|
||||||
const containers = truck.containers
|
|
||||||
.filter((c) => selectedContainers.includes(c.containerNumber))
|
|
||||||
.map((c) => ({
|
|
||||||
containerNumber: c.containerNumber,
|
|
||||||
returnDate,
|
|
||||||
facility: selectedWarehouse?.name || warehouse,
|
|
||||||
yard: selectedWarehouse?.code || undefined,
|
|
||||||
zone: undefined,
|
|
||||||
condition: condition || undefined,
|
|
||||||
handoverNote: handoverNote || undefined,
|
|
||||||
}));
|
|
||||||
|
|
||||||
onSubmit({
|
|
||||||
trucks: [{
|
|
||||||
bookingId: truck.bookingId,
|
|
||||||
customerId: truck.customerId,
|
|
||||||
containers,
|
|
||||||
}],
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal opened={opened} onClose={onClose} title="Process Empty Container Returns" size="lg">
|
|
||||||
{truck && (
|
|
||||||
<Stack gap="md">
|
|
||||||
<Group>
|
|
||||||
<Text fw={600}>{truck.plate}</Text>
|
|
||||||
<Text size="sm" c="dimmed">{truck.bookingRef}</Text>
|
|
||||||
</Group>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<Text size="sm" fw={600} mb="xs">Select containers to return:</Text>
|
|
||||||
<Stack gap="xs">
|
|
||||||
{truck.containers.map((container) => (
|
|
||||||
<Checkbox
|
|
||||||
key={container.containerNumber}
|
|
||||||
label={`${container.containerNumber} (${container.size || "bulk"})`}
|
|
||||||
checked={selectedContainers.includes(container.containerNumber)}
|
|
||||||
onChange={(e) => {
|
|
||||||
if (e.currentTarget.checked) {
|
|
||||||
setSelectedContainers([...selectedContainers, container.containerNumber]);
|
|
||||||
} else {
|
|
||||||
setSelectedContainers(selectedContainers.filter(c => c !== container.containerNumber));
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</Stack>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Select
|
|
||||||
label="Return Warehouse"
|
|
||||||
placeholder="Select warehouse for container return"
|
|
||||||
value={warehouse}
|
|
||||||
onChange={setWarehouse}
|
|
||||||
data={warehouseOptions}
|
|
||||||
required
|
|
||||||
searchable
|
|
||||||
/>
|
|
||||||
|
|
||||||
<TextInput
|
|
||||||
label="Return Date"
|
|
||||||
type="date"
|
|
||||||
value={returnDate}
|
|
||||||
onChange={(e) => setReturnDate(e.currentTarget.value)}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Textarea
|
|
||||||
label="Condition"
|
|
||||||
placeholder="Damage, residue, or cleanliness notes"
|
|
||||||
value={condition}
|
|
||||||
onChange={(e) => setCondition(e.currentTarget.value)}
|
|
||||||
rows={3}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Textarea
|
|
||||||
label="Handover Note"
|
|
||||||
placeholder="Consignee, trucker, or authorization notes"
|
|
||||||
value={handoverNote}
|
|
||||||
onChange={(e) => setHandoverNote(e.currentTarget.value)}
|
|
||||||
rows={3}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Group justify="flex-end" gap="sm">
|
|
||||||
<Button variant="default" onClick={onClose} disabled={loading}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={handleSubmit}
|
|
||||||
disabled={!selectedContainers.length || !warehouse}
|
|
||||||
loading={loading}
|
|
||||||
>
|
|
||||||
{selectedContainers.length > 1 ? "Bulk" : "Single"} Return ({selectedContainers.length})
|
|
||||||
</Button>
|
|
||||||
</Group>
|
|
||||||
</Stack>
|
|
||||||
)}
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -184,10 +184,20 @@ export default function CompanyProfileForm({
|
|||||||
// forms (plus a mandatory owner passport number).
|
// forms (plus a mandatory owner passport number).
|
||||||
const verifiedIdentity = identity?.faydaRequired === true;
|
const verifiedIdentity = identity?.faydaRequired === true;
|
||||||
|
|
||||||
|
// Which fields the current step renders an input for and therefore requires.
|
||||||
|
// Filled in further down (it depends on values this form owns), and read at
|
||||||
|
// validation time rather than at render time — the resolver below runs on
|
||||||
|
// events, always after the render that assigned it.
|
||||||
|
const requiredKeysRef = useRef<(keyof FormData)[]>([]);
|
||||||
|
|
||||||
const form = useForm<FormData>({
|
const form = useForm<FormData>({
|
||||||
resolver: zodResolver(
|
resolver: (values, context, options) =>
|
||||||
buildOnboardingSchema(identity?.passportRequired === true),
|
zodResolver(
|
||||||
|
buildOnboardingSchema(
|
||||||
|
identity?.passportRequired === true,
|
||||||
|
requiredKeysRef.current,
|
||||||
),
|
),
|
||||||
|
)(values, context, options),
|
||||||
// `values` below re-seeds the form whenever the profile is refetched — and
|
// `values` below re-seeds the form whenever the profile is refetched — and
|
||||||
// an in-page identity action (ticking "same as owner") refetches it. Without
|
// an in-page identity action (ticking "same as owner") refetches it. Without
|
||||||
// this, that reset silently throws away whatever the customer was part-way
|
// this, that reset silently throws away whatever the customer was part-way
|
||||||
@@ -432,15 +442,19 @@ export default function CompanyProfileForm({
|
|||||||
// Where the GM's details come from depends on how they were established: a
|
// Where the GM's details come from depends on how they were established: a
|
||||||
// Fayda verification (or a "same as owner" declaration) owns them outright,
|
// Fayda verification (or a "same as owner" declaration) owns them outright,
|
||||||
// and only a company that may still type them falls back to form state.
|
// and only a company that may still type them falls back to form state.
|
||||||
|
// A verified GM's identity wins, but Fayda's email and phone claims are
|
||||||
|
// optional: what the verification did not supply is typed on this step, and
|
||||||
|
// the API deliberately does not read those back onto the identity (they are
|
||||||
|
// not proven), so the form value is the only place they exist.
|
||||||
const gmVerified = identity?.gm.verified ?? false;
|
const gmVerified = identity?.gm.verified ?? false;
|
||||||
const gmName = gmVerified
|
const gmName = gmVerified
|
||||||
? (identity?.gm.name ?? "")
|
? firstPresent(identity?.gm.name, watch("generalManagerName"))
|
||||||
: watch("generalManagerName");
|
: watch("generalManagerName");
|
||||||
const gmEmail = gmVerified
|
const gmEmail = gmVerified
|
||||||
? (identity?.gm.email ?? "")
|
? firstPresent(identity?.gm.email, watch("generalManagerEmail"))
|
||||||
: watch("generalManagerEmail");
|
: watch("generalManagerEmail");
|
||||||
const gmPhone = gmVerified
|
const gmPhone = gmVerified
|
||||||
? (identity?.gm.phone ?? "")
|
? firstPresent(identity?.gm.phone, watch("generalManagerPhone"))
|
||||||
: watch("generalManagerPhone");
|
: watch("generalManagerPhone");
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -448,9 +462,11 @@ export default function CompanyProfileForm({
|
|||||||
* "same as owner" declaration, or (only where Fayda is optional) by typing.
|
* "same as owner" declaration, or (only where Fayda is optional) by typing.
|
||||||
* Fayda is an Ethiopian national ID, so a foreign company's GM may hold none.
|
* Fayda is an Ethiopian national ID, so a foreign company's GM may hold none.
|
||||||
*/
|
*/
|
||||||
|
// Matches what the API actually demands (`REQUIRED_COMPANY_INFO`): a name and
|
||||||
|
// a phone. The email is collected but optional — a manager proved through
|
||||||
|
// Fayda may have no email claim, and the notify resolver no longer needs one.
|
||||||
const gmTyped = Boolean(
|
const gmTyped = Boolean(
|
||||||
watch("generalManagerName")?.trim() &&
|
watch("generalManagerName")?.trim() &&
|
||||||
watch("generalManagerEmail")?.trim() &&
|
|
||||||
watch("generalManagerPhone")?.trim(),
|
watch("generalManagerPhone")?.trim(),
|
||||||
);
|
);
|
||||||
const gmEstablished =
|
const gmEstablished =
|
||||||
@@ -623,12 +639,23 @@ export default function CompanyProfileForm({
|
|||||||
// delegated, so it's required the moment a PoA exists. The API enforces the
|
// delegated, so it's required the moment a PoA exists. The API enforces the
|
||||||
// same rule on save, so skipping it here only costs the customer a
|
// same rule on save, so skipping it here only costs the customer a
|
||||||
// round-trip.
|
// round-trip.
|
||||||
// A PoA exists exactly when one has been verified — the details are the
|
//
|
||||||
// verification's output, so there is nothing else that could stand for one.
|
// "Exists" is the API's own test (`POA_ATTRIBUTES.some(...)`): ANY detail,
|
||||||
// Until then the upload is hidden: there is no representative for the paper
|
// verified or typed. Requiring a complete typed representative here instead
|
||||||
// to authorise, and a freight forwarder is held on the verification gate
|
// hid the upload from a customer who had entered only a name — for whom the
|
||||||
// below rather than on a file field it cannot yet fill.
|
// API still demands the paper, and whose resume would then be clamped back to
|
||||||
const poaProvided = (identity?.poa.verified ?? false) || poaTyped;
|
// this step with nothing on it to fill.
|
||||||
|
const poaAnyDetail = [
|
||||||
|
identity?.poa.name,
|
||||||
|
identity?.poa.email,
|
||||||
|
identity?.poa.phone,
|
||||||
|
identity?.poa.address,
|
||||||
|
watch("poaName"),
|
||||||
|
watch("poaEmail"),
|
||||||
|
watch("poaPhone"),
|
||||||
|
watch("poaLocation"),
|
||||||
|
].some((v) => v?.trim());
|
||||||
|
const poaProvided = (identity?.poa.verified ?? false) || poaAnyDetail;
|
||||||
// A freight forwarder owes the paper whether or not its representative could
|
// A freight forwarder owes the paper whether or not its representative could
|
||||||
// verify with Fayda — the API demands it at completion either way. Keying
|
// verify with Fayda — the API demands it at completion either way. Keying
|
||||||
// this on the verification alone hid the upload from a foreign forwarder and
|
// this on the verification alone hid the upload from a foreign forwarder and
|
||||||
@@ -641,6 +668,60 @@ export default function CompanyProfileForm({
|
|||||||
return Array.isArray(v) ? v.length > 0 : v != null;
|
return Array.isArray(v) ? v.length > 0 : v != null;
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What a Fayda verification did NOT supply, per person.
|
||||||
|
*
|
||||||
|
* Fayda's email, phone and address claims are optional and routinely come
|
||||||
|
* back empty, so a *verified* person can still be missing details the API
|
||||||
|
* demands (`REQUIRED_POA_FIELDS`, `REQUIRED_COMPANY_INFO`). Those gaps are
|
||||||
|
* typed instead — the API keeps exactly the keys a claim left empty typeable,
|
||||||
|
* since a claim that returned nothing owns no value to protect.
|
||||||
|
*
|
||||||
|
* Computed here, once, and handed to both the step (which renders an input
|
||||||
|
* per gap) and the schema (which requires exactly those) — a field is
|
||||||
|
* required if and only if there is an input on screen to fix it in.
|
||||||
|
*/
|
||||||
|
// Where Fayda is mandatory an unverified representative must verify rather
|
||||||
|
// than be typed, so nothing is offered until the verification lands.
|
||||||
|
const poaTypedAllowed =
|
||||||
|
!identity || identity.poa.verified || !identity.faydaRequired;
|
||||||
|
// A representative the verification never proved holds only typed details —
|
||||||
|
// the API leaves those unlocked, so the inputs stay on screen and stay
|
||||||
|
// editable. Only a *verified* PoA hides the fields their claim did fill,
|
||||||
|
// which is also the only case the API refuses to let anyone overwrite.
|
||||||
|
const poaGap = (v?: string | null) =>
|
||||||
|
poaTypedAllowed && (!identity?.poa.verified || !v?.trim());
|
||||||
|
const poaGaps = {
|
||||||
|
name: poaGap(identity?.poa.name),
|
||||||
|
email: poaGap(identity?.poa.email),
|
||||||
|
phone: poaGap(identity?.poa.phone),
|
||||||
|
address: poaGap(identity?.poa.address),
|
||||||
|
};
|
||||||
|
// The GM's own verification never falls back to the signed-in account — that
|
||||||
|
// account is the person onboarding, not necessarily the manager — so a GM
|
||||||
|
// verified with no email claim has nowhere else for one to come from.
|
||||||
|
// "Same as owner" is exempt: the API copies the owner's (account-backed)
|
||||||
|
// contact details across, so there is no gap and no input.
|
||||||
|
const gmGaps = {
|
||||||
|
email: !gmSameAsOwner && gmVerified && !identity?.gm.email?.trim(),
|
||||||
|
phone: !gmSameAsOwner && gmVerified && !identity?.gm.phone?.trim(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const requiredKeys: (keyof FormData)[] = [];
|
||||||
|
if (step === "personnel") {
|
||||||
|
// The manager's email is offered but not demanded: the API dropped it from
|
||||||
|
// `REQUIRED_COMPANY_INFO` once the notify resolver stopped depending on it.
|
||||||
|
// The phone is still required there, so it is still required here.
|
||||||
|
if (gmGaps.phone) requiredKeys.push("generalManagerPhone");
|
||||||
|
} else if (step === "poa" && delegationRequired) {
|
||||||
|
// Only once a PoA is required or provided: an untouched optional PoA is
|
||||||
|
// still a step the customer may walk straight past.
|
||||||
|
if (poaGaps.name) requiredKeys.push("poaName");
|
||||||
|
if (poaGaps.email) requiredKeys.push("poaEmail");
|
||||||
|
if (poaGaps.phone) requiredKeys.push("poaPhone");
|
||||||
|
}
|
||||||
|
requiredKeysRef.current = requiredKeys;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Collect the messages for a set of fields into one sentence.
|
* Collect the messages for a set of fields into one sentence.
|
||||||
*
|
*
|
||||||
@@ -656,6 +737,7 @@ export default function CompanyProfileForm({
|
|||||||
// yet, so the closure would still be holding the previous attempt's state.
|
// yet, so the closure would still be holding the previous attempt's state.
|
||||||
const parsed = buildOnboardingSchema(
|
const parsed = buildOnboardingSchema(
|
||||||
identity?.passportRequired === true,
|
identity?.passportRequired === true,
|
||||||
|
requiredKeys,
|
||||||
).safeParse(getValues());
|
).safeParse(getValues());
|
||||||
const wanted = new Set<string>(fields as string[]);
|
const wanted = new Set<string>(fields as string[]);
|
||||||
const messages = parsed.success
|
const messages = parsed.success
|
||||||
@@ -875,6 +957,7 @@ export default function CompanyProfileForm({
|
|||||||
onToggleGmSameAsOwner={toggleGmSameAsOwner}
|
onToggleGmSameAsOwner={toggleGmSameAsOwner}
|
||||||
gmLinkPending={gmLinkPending}
|
gmLinkPending={gmLinkPending}
|
||||||
gmVerified={gmVerified}
|
gmVerified={gmVerified}
|
||||||
|
gaps={gmGaps}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -892,6 +975,7 @@ export default function CompanyProfileForm({
|
|||||||
form={form}
|
form={form}
|
||||||
identity={identity}
|
identity={identity}
|
||||||
requirePoa={requirePoa}
|
requirePoa={requirePoa}
|
||||||
|
gaps={poaGaps}
|
||||||
delegationRequired={delegationRequired}
|
delegationRequired={delegationRequired}
|
||||||
poaDocumentSetting={poaDocumentSetting}
|
poaDocumentSetting={poaDocumentSetting}
|
||||||
documentFiles={documentFiles}
|
documentFiles={documentFiles}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
import { onboardingSchema, stepFields } from "./schema";
|
import { buildOnboardingSchema, onboardingSchema, stepFields } from "./schema";
|
||||||
import {
|
import {
|
||||||
firstPresent,
|
firstPresent,
|
||||||
firstValidEmail,
|
firstValidEmail,
|
||||||
@@ -101,6 +101,56 @@ describe("stepFields", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("buildOnboardingSchema (conditionally required fields)", () => {
|
||||||
|
const issuesFor = (
|
||||||
|
data: FormData,
|
||||||
|
required: (keyof FormData)[],
|
||||||
|
): (keyof FormData)[] => {
|
||||||
|
const parsed = buildOnboardingSchema(false, required).safeParse(data);
|
||||||
|
return parsed.success
|
||||||
|
? []
|
||||||
|
: (parsed.error.issues.map((i) => i.path[0]) as (keyof FormData)[]);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fayda's email/phone claims are optional: the step renders an input for what
|
||||||
|
// the verification did not supply, and requires exactly those. Nothing else —
|
||||||
|
// a field with no input on screen must never fail Continue.
|
||||||
|
it("requires only the keys it is handed", () => {
|
||||||
|
const issues = issuesFor(values(), [
|
||||||
|
"generalManagerEmail",
|
||||||
|
"generalManagerPhone",
|
||||||
|
]);
|
||||||
|
expect(issues).toEqual(["generalManagerEmail", "generalManagerPhone"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("passes once those keys are filled", () => {
|
||||||
|
expect(
|
||||||
|
issuesFor(
|
||||||
|
values({
|
||||||
|
generalManagerEmail: "gm@example.com",
|
||||||
|
generalManagerPhone: "+251911223344",
|
||||||
|
}),
|
||||||
|
["generalManagerEmail", "generalManagerPhone"],
|
||||||
|
),
|
||||||
|
).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves the same blank fields alone when nothing is required", () => {
|
||||||
|
expect(issuesFor(values(), [])).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("names the field in the message, so it reads under its own input", () => {
|
||||||
|
const parsed = buildOnboardingSchema(false, ["poaEmail"]).safeParse(
|
||||||
|
values(),
|
||||||
|
);
|
||||||
|
expect(parsed.success).toBe(false);
|
||||||
|
if (parsed.success) return;
|
||||||
|
expect(parsed.error.issues[0]?.message).toBe(
|
||||||
|
"Representative's email is required",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("stepPayload (company)", () => {
|
describe("stepPayload (company)", () => {
|
||||||
it("omits the eTrade bundle when nothing was re-verified", () => {
|
it("omits the eTrade bundle when nothing was re-verified", () => {
|
||||||
const payload = stepPayload("company", values(), {});
|
const payload = stepPayload("company", values(), {});
|
||||||
|
|||||||
@@ -105,27 +105,54 @@ export type FormData = z.infer<typeof onboardingSchema>;
|
|||||||
export const POA_DELEGATION_FILE_KEY = "poa_delegation_letter";
|
export const POA_DELEGATION_FILE_KEY = "poa_delegation_letter";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The PoA's identifying fields are never typed — they come from the Fayda
|
* Labels for the fields a Fayda verification may or may not have supplied. Which
|
||||||
* verification, whatever the company's nationality — so nothing here requires
|
* of them are mandatory is decided per render (see `requiredKeys` below), so the
|
||||||
* them. A freight forwarder's mandatory PoA is gated on the verification
|
* message has to be built here rather than attached to the base schema.
|
||||||
* itself, and its delegation letter alongside it, both in CompanyProfileForm
|
*/
|
||||||
* (files live outside form state).
|
const CONDITIONAL_LABELS: Partial<Record<keyof FormData, string>> = {
|
||||||
|
poaName: "Representative's name",
|
||||||
|
poaEmail: "Representative's email",
|
||||||
|
poaPhone: "Representative's phone",
|
||||||
|
generalManagerName: "General manager's name",
|
||||||
|
generalManagerEmail: "General manager's email",
|
||||||
|
generalManagerPhone: "General manager's phone",
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The PoA's and GM's identifying fields normally come from their Fayda
|
||||||
|
* verification, so nothing in the base schema requires them. But Fayda's email
|
||||||
|
* and phone claims are optional and routinely come back empty, and the steps
|
||||||
|
* render an input for whatever the verification did not supply — so those
|
||||||
|
* fields become mandatory exactly then.
|
||||||
*
|
*
|
||||||
* That leaves the owner's passport number as the only conditional field.
|
* `requiredKeys` is that decision, made by CompanyProfileForm from the same
|
||||||
|
* state that drives the rendering: a field is required iff an input exists for
|
||||||
|
* it. Passing it in (rather than deriving it here) is what keeps the two from
|
||||||
|
* drifting into a Continue button that fails on a field nobody can see.
|
||||||
*/
|
*/
|
||||||
export function buildOnboardingSchema(
|
export function buildOnboardingSchema(
|
||||||
/** True for a foreign company: the owner's passport number is mandatory. */
|
/** True for a foreign company: the owner's passport number is mandatory. */
|
||||||
passportRequired = false,
|
passportRequired = false,
|
||||||
|
/** Fields the current step renders an input for and must not leave blank. */
|
||||||
|
requiredKeys: readonly (keyof FormData)[] = [],
|
||||||
) {
|
) {
|
||||||
if (!passportRequired) return onboardingSchema;
|
if (!passportRequired && requiredKeys.length === 0) return onboardingSchema;
|
||||||
return onboardingSchema.superRefine((d, ctx) => {
|
return onboardingSchema.superRefine((d, ctx) => {
|
||||||
if (!d.ownerPassportNumber?.trim()) {
|
if (passportRequired && !d.ownerPassportNumber?.trim()) {
|
||||||
ctx.addIssue({
|
ctx.addIssue({
|
||||||
code: z.ZodIssueCode.custom,
|
code: z.ZodIssueCode.custom,
|
||||||
path: ["ownerPassportNumber"],
|
path: ["ownerPassportNumber"],
|
||||||
message: "The owner's passport number is required",
|
message: "The owner's passport number is required",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
for (const key of requiredKeys) {
|
||||||
|
if (d[key]?.trim()) continue;
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
path: [key],
|
||||||
|
message: `${CONDITIONAL_LABELS[key] ?? key} is required`,
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,12 @@ export interface PersonnelStepProps {
|
|||||||
/** A server-side "same as owner" declaration is in flight. */
|
/** A server-side "same as owner" declaration is in flight. */
|
||||||
gmLinkPending: boolean;
|
gmLinkPending: boolean;
|
||||||
gmVerified: boolean;
|
gmVerified: boolean;
|
||||||
|
/**
|
||||||
|
* Which of the manager's contact details their Fayda verification did not
|
||||||
|
* supply, and are therefore typed here. Computed by CompanyProfileForm, which
|
||||||
|
* requires exactly these in the schema.
|
||||||
|
*/
|
||||||
|
gaps: { email: boolean; phone: boolean };
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function PersonnelStep({
|
export default function PersonnelStep({
|
||||||
@@ -28,6 +34,7 @@ export default function PersonnelStep({
|
|||||||
onToggleGmSameAsOwner,
|
onToggleGmSameAsOwner,
|
||||||
gmLinkPending,
|
gmLinkPending,
|
||||||
gmVerified,
|
gmVerified,
|
||||||
|
gaps,
|
||||||
}: PersonnelStepProps) {
|
}: PersonnelStepProps) {
|
||||||
const {
|
const {
|
||||||
register,
|
register,
|
||||||
@@ -74,6 +81,34 @@ export default function PersonnelStep({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Fayda's email and phone claims are optional, and the manager's own
|
||||||
|
verification has no account to fall back on the way the owner's does
|
||||||
|
— the person onboarding is not necessarily the manager. Whatever the
|
||||||
|
verification left empty is typed here, and required: without it the
|
||||||
|
submit fails on "Add your general manager email" with no field
|
||||||
|
anywhere to satisfy it. */}
|
||||||
|
{(gaps.email || gaps.phone) && (
|
||||||
|
<SimpleGrid cols={gaps.email && gaps.phone ? 2 : 1} spacing="md">
|
||||||
|
{gaps.email && (
|
||||||
|
<TextInput
|
||||||
|
label="Email"
|
||||||
|
type="email"
|
||||||
|
placeholder="gm@company.com"
|
||||||
|
error={errors.generalManagerEmail?.message}
|
||||||
|
{...register("generalManagerEmail")}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{gaps.phone && (
|
||||||
|
<ControlledPhoneField
|
||||||
|
control={control}
|
||||||
|
name="generalManagerPhone"
|
||||||
|
label="Phone"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</SimpleGrid>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Typed details survive only where Fayda cannot be required —
|
{/* Typed details survive only where Fayda cannot be required —
|
||||||
a foreign company's manager may hold no Fayda ID. Once
|
a foreign company's manager may hold no Fayda ID. Once
|
||||||
verified the API owns these fields, so they go away. */}
|
verified the API owns these fields, so they go away. */}
|
||||||
|
|||||||
@@ -14,6 +14,13 @@ export interface PoaStepProps {
|
|||||||
identity?: CompanyIdentityState;
|
identity?: CompanyIdentityState;
|
||||||
/** This company holds a freight-forwarder profile, so the PoA is mandatory. */
|
/** This company holds a freight-forwarder profile, so the PoA is mandatory. */
|
||||||
requirePoa: boolean;
|
requirePoa: boolean;
|
||||||
|
/**
|
||||||
|
* Which of the representative's details the Fayda verification did not
|
||||||
|
* supply, and are therefore typed here. Computed by CompanyProfileForm, which
|
||||||
|
* requires exactly these in the schema — so every input rendered below is one
|
||||||
|
* the customer is actually asked to fill.
|
||||||
|
*/
|
||||||
|
gaps: { name: boolean; email: boolean; phone: boolean; address: boolean };
|
||||||
/** The DARS delegation paper is owed (a PoA exists, or the company forwards). */
|
/** The DARS delegation paper is owed (a PoA exists, or the company forwards). */
|
||||||
delegationRequired: boolean;
|
delegationRequired: boolean;
|
||||||
/** Single-field upload setting carrying just the delegation letter. */
|
/** Single-field upload setting carrying just the delegation letter. */
|
||||||
@@ -28,6 +35,7 @@ export default function PoaStep({
|
|||||||
form,
|
form,
|
||||||
identity,
|
identity,
|
||||||
requirePoa,
|
requirePoa,
|
||||||
|
gaps,
|
||||||
delegationRequired,
|
delegationRequired,
|
||||||
poaDocumentSetting,
|
poaDocumentSetting,
|
||||||
documentFiles,
|
documentFiles,
|
||||||
@@ -45,24 +53,21 @@ export default function PoaStep({
|
|||||||
// empty, so a *verified* representative can still be missing the email and
|
// empty, so a *verified* representative can still be missing the email and
|
||||||
// phone the API demands from a freight forwarder (`REQUIRED_POA_FIELDS`) —
|
// phone the API demands from a freight forwarder (`REQUIRED_POA_FIELDS`) —
|
||||||
// and the panel above renders no input for them, which dead-ends the step on
|
// and the panel above renders no input for them, which dead-ends the step on
|
||||||
// "Add the poa email first". Offer an input for whatever the verification
|
// "Add the poa email first". `gaps` is exactly what the verification did not
|
||||||
// did not supply: the API keeps exactly those keys typeable, since a claim
|
// supply: the API keeps those keys typeable, since a claim that returned
|
||||||
// that returned nothing owns no value to protect (`faydaOwnedKeys`).
|
// nothing owns no value to protect (`faydaOwnedKeys`).
|
||||||
const poa = identity?.poa;
|
const needsEmail = gaps.email;
|
||||||
// Where Fayda is mandatory an unverified representative must verify rather
|
const needsPhone = gaps.phone;
|
||||||
// than be typed, so nothing is offered until the verification lands.
|
|
||||||
const typedAllowed = !identity || poa!.verified || !identity.faydaRequired;
|
|
||||||
const missing = (value: string | null | undefined) =>
|
|
||||||
typedAllowed && !value?.trim();
|
|
||||||
const needsEmail = missing(poa?.email);
|
|
||||||
const needsPhone = missing(poa?.phone);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Text size="sm" c="edr-muted">
|
<Text size="sm" c="edr-muted">
|
||||||
{requirePoa
|
{requirePoa
|
||||||
? "As a freight forwarder you act on other companies' behalf, so Power of Attorney details and the DARS delegation paper are required."
|
? "As a freight forwarder you act on other companies' behalf, so Power of Attorney details and the DARS delegation paper are required."
|
||||||
: "Power of Attorney details are optional. Fill them in if you have them, or skip to continue. If you do enter a representative, upload the delegation paper authenticated by DARS."}
|
: "Power of Attorney details are optional. Fill them in if you have them, or skip to continue. If you do enter a representative, upload the delegation paper authenticated by DARS."}{" "}
|
||||||
|
{/* The API refuses an owner who delegates to themselves — say so here,
|
||||||
|
or the customer only finds out after being sent to Fayda and back. */}
|
||||||
|
The representative must be someone other than the company's owner.
|
||||||
</Text>
|
</Text>
|
||||||
{/* A representative acts for the company inside Ethiopia
|
{/* A representative acts for the company inside Ethiopia
|
||||||
whoever owns it, so the PoA is proven with Fayda regardless of
|
whoever owns it, so the PoA is proven with Fayda regardless of
|
||||||
@@ -78,7 +83,7 @@ export default function PoaStep({
|
|||||||
)}
|
)}
|
||||||
{/* Whatever the Fayda claim did carry is shown on the panel above and
|
{/* Whatever the Fayda claim did carry is shown on the panel above and
|
||||||
is never typed here — the verification owns it. */}
|
is never typed here — the verification owns it. */}
|
||||||
{missing(poa?.name) && (
|
{gaps.name && (
|
||||||
<TextInput
|
<TextInput
|
||||||
label="Representative's Name"
|
label="Representative's Name"
|
||||||
placeholder="Abebe Bikila"
|
placeholder="Abebe Bikila"
|
||||||
@@ -106,7 +111,7 @@ export default function PoaStep({
|
|||||||
)}
|
)}
|
||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
)}
|
)}
|
||||||
{missing(poa?.address) && (
|
{gaps.address && (
|
||||||
<TextInput
|
<TextInput
|
||||||
label="PoA Location"
|
label="PoA Location"
|
||||||
placeholder="City, Country"
|
placeholder="City, Country"
|
||||||
|
|||||||
@@ -127,9 +127,13 @@ export default function TabGeneralManager({ profile, mode = "edit", onContinue }
|
|||||||
const mutation = useMutation({
|
const mutation = useMutation({
|
||||||
mutationFn: (data: FormData) =>
|
mutationFn: (data: FormData) =>
|
||||||
api.companies.updateProfile.call({
|
api.companies.updateProfile.call({
|
||||||
generalManagerName: data.generalManagerName,
|
// `|| undefined`, never "": the DTO's `@IsOptional()` only skips null
|
||||||
generalManagerEmail: data.generalManagerEmail,
|
// and undefined, so an empty string is validated and 400s with
|
||||||
generalManagerPhone: data.generalManagerPhone,
|
// "generalManagerEmail must be an email". A verified manager legitimately
|
||||||
|
// leaves the fields Fayda did supply blank here.
|
||||||
|
generalManagerName: data.generalManagerName || undefined,
|
||||||
|
generalManagerEmail: data.generalManagerEmail || undefined,
|
||||||
|
generalManagerPhone: data.generalManagerPhone || undefined,
|
||||||
}),
|
}),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: api.companies.getProfile.queryKey() });
|
queryClient.invalidateQueries({ queryKey: api.companies.getProfile.queryKey() });
|
||||||
@@ -144,6 +148,16 @@ export default function TabGeneralManager({ profile, mode = "edit", onContinue }
|
|||||||
// posting empty strings over a proven identity.
|
// posting empty strings over a proven identity.
|
||||||
const typedFieldsInUse = !gmSameAsOwner && !gm?.verified && !faydaRequired;
|
const typedFieldsInUse = !gmSameAsOwner && !gm?.verified && !faydaRequired;
|
||||||
|
|
||||||
|
// Except for what the verification never supplied. Fayda's email and phone
|
||||||
|
// claims are optional, and a manager verified without them has no account to
|
||||||
|
// fall back on the way the owner does — so those stay typed, here as well as
|
||||||
|
// in onboarding, or a wrong address could never be corrected.
|
||||||
|
const gmGaps = {
|
||||||
|
email: !gmSameAsOwner && Boolean(gm?.verified) && !gm?.email?.trim(),
|
||||||
|
phone: !gmSameAsOwner && Boolean(gm?.verified) && !gm?.phone?.trim(),
|
||||||
|
};
|
||||||
|
const savable = typedFieldsInUse || gmGaps.email || gmGaps.phone;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card padding="lg">
|
<Card padding="lg">
|
||||||
<Group gap="sm" mb="xs">
|
<Group gap="sm" mb="xs">
|
||||||
@@ -187,6 +201,34 @@ export default function TabGeneralManager({ profile, mode = "edit", onContinue }
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Whatever the verification did not supply is typed instead — the
|
||||||
|
API keeps exactly those keys writable. */}
|
||||||
|
{(gmGaps.email || gmGaps.phone) && (
|
||||||
|
<Grid>
|
||||||
|
{gmGaps.email && (
|
||||||
|
<Grid.Col span={gmGaps.phone ? 6 : 12}>
|
||||||
|
<TextInput
|
||||||
|
label="Email Address"
|
||||||
|
type="email"
|
||||||
|
placeholder="gm@company.com"
|
||||||
|
error={errors.generalManagerEmail?.message}
|
||||||
|
{...register("generalManagerEmail")}
|
||||||
|
/>
|
||||||
|
</Grid.Col>
|
||||||
|
)}
|
||||||
|
{gmGaps.phone && (
|
||||||
|
<Grid.Col span={gmGaps.email ? 6 : 12}>
|
||||||
|
<ControlledPhoneField
|
||||||
|
control={control}
|
||||||
|
name="generalManagerPhone"
|
||||||
|
label="Phone Number"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</Grid.Col>
|
||||||
|
)}
|
||||||
|
</Grid>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Typed details survive only where Fayda cannot be required — a
|
{/* Typed details survive only where Fayda cannot be required — a
|
||||||
foreign company's manager may hold no Fayda ID. Once verified the
|
foreign company's manager may hold no Fayda ID. Once verified the
|
||||||
API owns these fields and refuses edits, so they go away. */}
|
API owns these fields and refuses edits, so they go away. */}
|
||||||
@@ -258,7 +300,7 @@ export default function TabGeneralManager({ profile, mode = "edit", onContinue }
|
|||||||
empty strings at an identity the API owns and refuses to
|
empty strings at an identity the API owns and refuses to
|
||||||
overwrite. Onboarding still needs a way forward, so the button
|
overwrite. Onboarding still needs a way forward, so the button
|
||||||
becomes a plain Continue rather than disappearing. */}
|
becomes a plain Continue rather than disappearing. */}
|
||||||
{typedFieldsInUse ? (
|
{savable ? (
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
leftSection={<Save size={16} />}
|
leftSection={<Save size={16} />}
|
||||||
|
|||||||
BIN
dump-edr_prod-202608092016.sql
Normal file
BIN
dump-edr_prod-202608092016.sql
Normal file
Binary file not shown.
BIN
dump-smart_office_prod-202608092053.sql
Normal file
BIN
dump-smart_office_prod-202608092053.sql
Normal file
Binary file not shown.
12736
final_migration/edr_dev-freight-portable.sql
Normal file
12736
final_migration/edr_dev-freight-portable.sql
Normal file
File diff suppressed because one or more lines are too long
BIN
freight-backup-20260809.dump
Normal file
BIN
freight-backup-20260809.dump
Normal file
Binary file not shown.
BIN
freight-backup-preswap-20260809.dump
Normal file
BIN
freight-backup-preswap-20260809.dump
Normal file
Binary file not shown.
301
migrations-restore-299-rows.sql
Normal file
301
migrations-restore-299-rows.sql
Normal file
@@ -0,0 +1,301 @@
|
|||||||
|
-- restore deleted pre-baseline migration rows
|
||||||
|
INSERT INTO freight.migrations (timestamp, name) VALUES
|
||||||
|
(1748427600000, 'AddServiceTypesAndCargoTypes1748427600000'),
|
||||||
|
(1748514000000, 'AddRuleEngineTablesAndCodes1748514000000'),
|
||||||
|
(1748550000000, 'CreateFreightLegacyBaseline1748550000000'),
|
||||||
|
(1748600000000, 'ItmlsFullSchemaRewrite1748600000000'),
|
||||||
|
(1748700000000, 'AddBookingsConfigForeignKeys1748700000000'),
|
||||||
|
(1748800000000, 'AddBookingsRemainingForeignKeys1748800000000'),
|
||||||
|
(1748900000000, 'MoveCustomersToFreightSchema1748900000000'),
|
||||||
|
(1749000000000, 'NormalizeWeightLimitTradeDirectionBoth1749000000000'),
|
||||||
|
(1749100000000, 'CreateFreightFilesTable1749100000000'),
|
||||||
|
(1749200000000, 'CreateCompaniesModule1749200000000'),
|
||||||
|
(1749200000000, 'BookingFlowRefactor1749200000000'),
|
||||||
|
(1749300000000, 'AddFanNumberToCompanies1749300000000'),
|
||||||
|
(1749300000000, 'AddBookingFreightType1749300000000'),
|
||||||
|
(1749400000000, 'AddTrainScheduling1749400000000'),
|
||||||
|
(1749400000000, 'AddContractSignatures1749400000000'),
|
||||||
|
(1749500000000, 'AddCompanyIdToBookings1749500000000'),
|
||||||
|
(1749600000000, 'AddBlocksRoleToApprovalStep1749600000000'),
|
||||||
|
(1749700000000, 'SeedDefaultApprovalRules1749700000000'),
|
||||||
|
(1749800000000, 'AddShippingLinesCodeUniqueIndex1749800000000'),
|
||||||
|
(1749900000000, 'CreateFileUploadSettingsTables1749900000000'),
|
||||||
|
(1750000000000, 'CreateFacilitiesTable1750000000000'),
|
||||||
|
(1750000000000, 'AddTrainExtendedColumns1750000000000'),
|
||||||
|
(1750000000000, 'AddCompanyContactColumns1750000000000'),
|
||||||
|
(1750000000001, 'AddFacilityIdToWarehouses1750000000001'),
|
||||||
|
(1750000000002, 'AddProofOfDeliveryToCargoes1750000000002'),
|
||||||
|
(1750000000003, 'AddWarehouseInspection1750000000003'),
|
||||||
|
(1750100000000, 'CreateFleetCrudTables1750100000000'),
|
||||||
|
(1750100000000, 'AddRoutesAndExtendLocomotives1750100000000'),
|
||||||
|
(1750200000000, 'SeedDefaultWagonTypes1750200000000'),
|
||||||
|
(1750200000000, 'AddPhysicalWagonToTrainSetWagons1750200000000'),
|
||||||
|
(1750300000000, 'AddRouteToTrainSchedules1750300000000'),
|
||||||
|
(1750300000000, 'AddCurrentLocationToWagons1750300000000'),
|
||||||
|
(1750400000000, 'SeedEdRWagonFleet1750400000000'),
|
||||||
|
(1750400000000, 'AddSchedulingAllocationEnhancements1750400000000'),
|
||||||
|
(1750500000000, 'AddWagonReadiness1750500000000'),
|
||||||
|
(1750600000000, 'AddGovernmentBookingFields1750600000000'),
|
||||||
|
(1750700000000, 'CreateSchedulingEvents1750700000000'),
|
||||||
|
(1750800000000, 'FixContainerWagonsPerUnit1750800000000'),
|
||||||
|
(1750900000000, 'AddContainerNumberToBookingContainer1750900000000'),
|
||||||
|
(1751000000000, 'CreateTrainSchedulingGlobalRules1751000000000'),
|
||||||
|
(1751000000001, 'AddDeletedAtToTrainSchedulingGlobalRules1751000000001'),
|
||||||
|
(1752000000000, 'CreateCompanyProfiles1752000000000'),
|
||||||
|
(1752000000001, 'MoveBusinessLicenseToProfile1752000000001'),
|
||||||
|
(1770000000000, 'CreateVehiclesTable1770000000000'),
|
||||||
|
(1775000000000, 'CreateDriversTable1775000000000'),
|
||||||
|
(1780639311366, 'CreatePaymentTable1780639311366'),
|
||||||
|
(1780639978834, 'AlterClientActionToJsonb1780639978834'),
|
||||||
|
(1780644945086, 'UpdatePaymentTimestamp1780644945086'),
|
||||||
|
(1781000000000, 'AddLocomotiveReadiness1781000000000'),
|
||||||
|
(1781000000001, 'CreateTrainCheckpointEvents1781000000001'),
|
||||||
|
(1781000000002, 'AddBatchBookingFields1781000000002'),
|
||||||
|
(1781000000003, 'AddSelectedForBatchStatus1781000000003'),
|
||||||
|
(1781000000004, 'AddDomesticWeightLimitTradeDirection1781000000004'),
|
||||||
|
(1781000000005, 'CreateTrainCompositionRemovalLog1781000000005'),
|
||||||
|
(1782000000000, 'WagonLocomotiveYardLink1782000000000'),
|
||||||
|
(1782000000001, 'AddPaymentWebhookEventAndRefund1782000000001'),
|
||||||
|
(1782000000002, 'ExtendPaymentMethodEnum1782000000002'),
|
||||||
|
(1783000000000, 'ReplacePriorityRulesWithPriorityConfigs1783000000000'),
|
||||||
|
(1784000000000, 'CreateSavedSignatures1784000000000'),
|
||||||
|
(1784000000001, 'SeedWagonsWithYardAssignment1784000000001'),
|
||||||
|
(1784100000000, 'AddBookingRouteDayIndex1784100000000'),
|
||||||
|
(1790000000000, 'CreateWarehouseModule1790000000000'),
|
||||||
|
(1790000000001, 'WarehouseBatch21790000000001'),
|
||||||
|
(1790000000002, 'WarehouseBatch31790000000002'),
|
||||||
|
(1791000000000, 'AddWarehouseAllocationAndFeeRules1791000000000'),
|
||||||
|
(1791000000000, 'AddActiveModeAndOnboardingToExternalProfiles1791000000000'),
|
||||||
|
(1791000000001, 'AddWarehouseFeeInvoices1791000000001'),
|
||||||
|
(1791000000001, 'AddCompanyProfileIdToBookings1791000000001'),
|
||||||
|
(1791000000002, 'AddNationalityToCompanies1791000000002'),
|
||||||
|
(1791000000002, 'AddImportPickupDeliveryColumns1791000000002'),
|
||||||
|
(1791000000003, 'AddInventoryUnloadedAt1791000000003'),
|
||||||
|
(1791000000003, 'AddETradeFieldsToCompanies1791000000003'),
|
||||||
|
(1791000000003, 'AddBusinessLicenseFilesToCompanyProfiles1791000000003'),
|
||||||
|
(1791000000004, 'AddFacilityIdToWarehousesFix1791000000004'),
|
||||||
|
(1791000000005, 'AddWarehouseInventoryInspectionStatusFix1791000000005'),
|
||||||
|
(1791999999999, 'CreateDropdownSettings1791999999999'),
|
||||||
|
(1792000000000, 'AddUnitOfMeasureToCargoTypes1792000000000'),
|
||||||
|
(1792000000001, 'AddBookingTypeAndContractFields1792000000001'),
|
||||||
|
(1792000000002, 'CreateBookingOrders1792000000002'),
|
||||||
|
(1792000000003, 'SeedGeneralContractPeriod1792000000003'),
|
||||||
|
(1792000000004, 'SeedContractValidityPeriods1792000000004'),
|
||||||
|
(1800000000001, 'AddVehicleDriverAssignment1800000000001'),
|
||||||
|
(1810000000000, 'CreateFirstMile1810000000000'),
|
||||||
|
(1810000000001, 'CreateLastMile1810000000001'),
|
||||||
|
(1810000000002, 'MakeCompanyProfileReferenceNullable1810000000002'),
|
||||||
|
(1810000000002, 'CreateLastMileContainerAllocations1810000000002'),
|
||||||
|
(1810000000002, 'AddVehicleCodeAndPlates1810000000002'),
|
||||||
|
(1810000000003, 'CreateOtpVerifications1810000000003'),
|
||||||
|
(1810000000004, 'AddPostPaymentCompletedColumn1810000000004'),
|
||||||
|
(1820000000000, 'DropAllowConsolidation1820000000000'),
|
||||||
|
(1820000000001, 'CreateContractRouteLines1820000000001'),
|
||||||
|
(1820000000002, 'CreateBookingDocumentReview1820000000002'),
|
||||||
|
(1820000000003, 'AddPriceAdjustment1820000000003'),
|
||||||
|
(1820000000004, 'FoldSurchargeTypesIntoRates1820000000004'),
|
||||||
|
(1820000000005, 'AddContractValidityWindow1820000000005'),
|
||||||
|
(1820000000006, 'AddCustomsAgentAndMileCoordinates1820000000006'),
|
||||||
|
(1820000000010, 'AddGeneralContractOrderFields1820000000010'),
|
||||||
|
(1820000000011, 'DropEmailPhoneFromExternalProfiles1820000000011'),
|
||||||
|
(1820000000011, 'AddTrainSetLocomotives1820000000011'),
|
||||||
|
(1820000000012, 'AddEstimatedShipmentDate1820000000012'),
|
||||||
|
(1821000000000, 'CreateInterchangeDocuments1821000000000'),
|
||||||
|
(1821000000001, 'EnsureWarehouseInventoryInspectionStatus1821000000001'),
|
||||||
|
(1821000000002, 'CreateInvoices1821000000002'),
|
||||||
|
(1821000000002, 'AddDistanceColumnsToVehicles1821000000002'),
|
||||||
|
(1821000000003, 'AddCompanyKindAndGovBookingLinks1821000000003'),
|
||||||
|
(1821000000004, 'MakePaymentsTypeGeneric1821000000004'),
|
||||||
|
(1822000000000, 'CreateImportDjiboutiOperations1822000000000'),
|
||||||
|
(1822000000000, 'CreateContracts1822000000000'),
|
||||||
|
(1823000000000, 'CreateImportOperationsTables1823000000000'),
|
||||||
|
(1823000000000, 'BackfillContractsFromBookings1823000000000'),
|
||||||
|
(1824000000000, 'DropLegacyContractTables1824000000000'),
|
||||||
|
(1825000000000, 'CreateBookingContainerAllocations1825000000000'),
|
||||||
|
(1825000000000, 'AddGlOperations1825000000000'),
|
||||||
|
(1826000000000, 'AddCargoScopeQuantityCap1826000000000'),
|
||||||
|
(1827000000000, 'CreateBookingRequests1827000000000'),
|
||||||
|
(1828000000000, 'ExtendInvoicesForPartialPayment1828000000000'),
|
||||||
|
(1828000000000, 'AddGrnNumberToWarehouseInventory1828000000000'),
|
||||||
|
(1828000000000, 'AddBulkHazmatReeferQuantity1828000000000'),
|
||||||
|
(1829000000000, 'PhasedClearanceCycleMeta1829000000000'),
|
||||||
|
(1829000000000, 'CentralizeWarehouseInvoices1829000000000'),
|
||||||
|
(1829000000001, 'SeedRoVesselMinDays1829000000001'),
|
||||||
|
(1829000000002, 'BookingClearanceMeta1829000000002'),
|
||||||
|
(1830000000000, 'DropCargoTypeShowFreeTextBox1830000000000'),
|
||||||
|
(1830000000000, 'CreateFirstMileContainerAllocations1830000000000'),
|
||||||
|
(1830000000000, 'AddExpiredInvoiceStatus1830000000000'),
|
||||||
|
(1830000000001, 'RouteStatusAndSegmentKm1830000000001'),
|
||||||
|
(1830000000002, 'PreClearanceFinalizedAt1830000000002'),
|
||||||
|
(1831000000000, 'AddWarehouseFeeRuleTiers1831000000000'),
|
||||||
|
(1832000000000, 'AddCustomerTruckAssignmentToBookings1832000000000'),
|
||||||
|
(1840000000000, 'CreateFuelTables1840000000000'),
|
||||||
|
(1850000000000, 'CreateMaintenanceTables1850000000000'),
|
||||||
|
(1860000000000, 'AddPaidToFirstAndLastMile1860000000000'),
|
||||||
|
(1861000000000, 'AddBookingWindowGlobalRules1861000000000'),
|
||||||
|
(1861000000001, 'ReleaseStuckAssignedLocomotives1861000000001'),
|
||||||
|
(1862000000000, 'AddScheduleWindowPhases1862000000000'),
|
||||||
|
(1863000000000, 'CreateBookingBatchOffers1863000000000'),
|
||||||
|
(1870000000000, 'RepairSynchronizeDrift1870000000000'),
|
||||||
|
(1870000000000, 'AddLocationToVehicles1870000000000'),
|
||||||
|
(1880000000000, 'AddVehicleStatuses1880000000000'),
|
||||||
|
(1890000000000, 'SeparateVehicleAvailability1890000000000'),
|
||||||
|
(1890000000001, 'AddVehicleCodeAndPlates1890000000001'),
|
||||||
|
(1890000000002, 'AddFaydaVerificationSessions1890000000002'),
|
||||||
|
(1890000000003, 'AddDriverFaydaVerification1890000000003'),
|
||||||
|
(1890000000004, 'AddLastMileVehicleAssignments1890000000004'),
|
||||||
|
(1890000000005, 'AddDriverGender1890000000005'),
|
||||||
|
(1890000000006, 'AddDriverFaydaSubUnique1890000000006'),
|
||||||
|
(1890000000007, 'DriverUniquePartialSoftDelete1890000000007'),
|
||||||
|
(1890000000008, 'AddFleetEvents1890000000008'),
|
||||||
|
(1890000000009, 'AddLastMileAssignmentContainerNumber1890000000009'),
|
||||||
|
(1890000000010, 'AddLastMileAssignmentDistance1890000000010'),
|
||||||
|
(1900000000000, 'SimplifyRatesAndWeightLimitRules1900000000000'),
|
||||||
|
(1900000000000, 'AddLoadingStatusToTrainScheduleBookings1900000000000'),
|
||||||
|
(1900000000000, 'AddEmailToOtpVerifications1900000000000'),
|
||||||
|
(1910000000000, 'WidenWindowDurationHoursPrecision1910000000000'),
|
||||||
|
(1920000000000, 'AddScheduleWindowRuleSnapshot1920000000000'),
|
||||||
|
(1930000000000, 'AddMaxCapacityToWeightLimitRules1930000000000'),
|
||||||
|
(1940000000000, 'AddWagonTypeFkToCargoAndContainerTypes1940000000000'),
|
||||||
|
(1940000000000, 'AddFirstMileVehicleAssignments1940000000000'),
|
||||||
|
(1950000000000, 'CreateNotifications1950000000000'),
|
||||||
|
(1950000000000, 'AddWindowCloseHour1950000000000'),
|
||||||
|
(1950000000000, 'AddVehicleCompliance1950000000000'),
|
||||||
|
(1950000000000, 'AddCustomerTruckAssignments1950000000000'),
|
||||||
|
(1960000000000, 'AddIncidents1960000000000'),
|
||||||
|
(1960000000000, 'AddContainerReceiptToBookingContainerUnits1960000000000'),
|
||||||
|
(1970000000000, 'AddMaintenanceDepth1970000000000'),
|
||||||
|
(1970000000000, 'AddCustomerTruckDeparture1970000000000'),
|
||||||
|
(1980000000000, 'YardCountryEnumAndRouteDirection1980000000000'),
|
||||||
|
(1980000000000, 'AddProcurement1980000000000'),
|
||||||
|
(1980000000000, 'AddBookingHandovers1980000000000'),
|
||||||
|
(1990000000000, 'SegmentCorridorBookings1990000000000'),
|
||||||
|
(1990000000000, 'AddVehiclePricePerKm1990000000000'),
|
||||||
|
(1990000000000, 'AddDoubleHandlingBasisAndMachinery1990000000000'),
|
||||||
|
(2000000000000, 'CreateCompanyChangeRequest2000000000000'),
|
||||||
|
(2000000000000, 'AddTruckDetentionTiming2000000000000'),
|
||||||
|
(2000000000000, 'AddGpsTracking2000000000000'),
|
||||||
|
(2000000000000, 'AddCustomsPriorityConfig2000000000000'),
|
||||||
|
(2000000000001, 'AddCompanyProfileReview2000000000001'),
|
||||||
|
(2010000000000, 'AddFeeRuleVehicleType2010000000000'),
|
||||||
|
(2010000000000, 'AddConsolidationResumeStatus2010000000000'),
|
||||||
|
(2020000000000, 'WarehouseCapacityKgToTons2020000000000'),
|
||||||
|
(2020000000000, 'RepairOtpEmailSchema2020000000000'),
|
||||||
|
(2030000000000, 'AddTrainScheduleReference2030000000000'),
|
||||||
|
(2040000000000, 'MigrateLicenseFilesToFileRecords2040000000000'),
|
||||||
|
(2040000000000, 'AddLocomotiveOverageTolerance2040000000000'),
|
||||||
|
(2050000000000, 'DropWagonTypeMaxWagonsPerTrain2050000000000'),
|
||||||
|
(2050000000000, 'AddCustomerTruckContainerLoadedAt2050000000000'),
|
||||||
|
(2060000000000, 'SeedRailWagonTypes2060000000000'),
|
||||||
|
(2060000000000, 'CreateYardDistances2060000000000'),
|
||||||
|
(2070000000000, 'MakeWagonTypeTareWeightRequired2070000000000'),
|
||||||
|
(2080000000000, 'DropWagonSpecColumns2080000000000'),
|
||||||
|
(2090000000000, 'RepairGrnNumberColumn2090000000000'),
|
||||||
|
(2090000000000, 'CreateContractTemplates2090000000000'),
|
||||||
|
(2100000000000, 'WarehouseLoadingTrainAssociation2100000000000'),
|
||||||
|
(2100000000000, 'CompanyProfileDefaultPending2100000000000'),
|
||||||
|
(2110000000000, 'RepairVehicleAvailabilityColumn2110000000000'),
|
||||||
|
(2110000000000, 'AddBookingIsSplit2110000000000'),
|
||||||
|
(2120000000000, 'AddScheduleWagonAllocationSnapshot2120000000000'),
|
||||||
|
(2120000000000, 'AddLastMileProofOfDelivery2120000000000'),
|
||||||
|
(2130000000000, 'AddHandoverSignerName2130000000000'),
|
||||||
|
(2140000000000, 'CreateAccrualAcks2140000000000'),
|
||||||
|
(2150000000000, 'TrainBuilder2150000000000'),
|
||||||
|
(2160000000000, 'MultiWagonTypePerCargoAndContainer2160000000000'),
|
||||||
|
(2170000000000, 'ScheduleWagonAdjustmentLogs2170000000000'),
|
||||||
|
(2170000000000, 'CreateWagonTransferRequests2170000000000'),
|
||||||
|
(2180000000000, 'LinkWagonMovementToTransferRequest2180000000000'),
|
||||||
|
(2190000000000, 'DropReopenDelayMinutes2190000000000'),
|
||||||
|
(2200000000000, 'TrainNumberPair2200000000000'),
|
||||||
|
(2210000000000, 'ScheduleScopedWagonPins2210000000000'),
|
||||||
|
(2220000000000, 'AddContractDocumentSnapshot2220000000000'),
|
||||||
|
(2230000000000, 'RenameWagonStatusRetiredToDetained2230000000000'),
|
||||||
|
(2240000000000, 'AddTransferRequestReason2240000000000'),
|
||||||
|
(2250000000000, 'CreatePriorityRuleChangeRequests2250000000000'),
|
||||||
|
(2260000000000, 'SeedEdrWagonFleetErNumbering2260000000000'),
|
||||||
|
(2260000000000, 'AddLastMileTruckArrivalDeparture2260000000000'),
|
||||||
|
(2260000000000, 'AddClearanceFeePayment2260000000000'),
|
||||||
|
(2270000000000, 'AddWagonTrainNumbers2270000000000'),
|
||||||
|
(2270000000000, 'AddContainerReturnQuantity2270000000000'),
|
||||||
|
(2280000000000, 'WagonNumberPartialUnique2280000000000'),
|
||||||
|
(2280000000000, 'SeedWagonRunNumbers2280000000000'),
|
||||||
|
(2290000000000, 'YardFacilities2290000000000'),
|
||||||
|
(2290000000000, 'SeedWagonYardDoraleh2290000000000'),
|
||||||
|
(2290000000000, 'DropContainerWagonsPerUnit2290000000000'),
|
||||||
|
(2300000000000, 'RepairGpsTrackingTables2300000000000'),
|
||||||
|
(2300000000000, 'CreateRateChangeRequests2300000000000'),
|
||||||
|
(2310000000000, 'CreateSupportChat2310000000000'),
|
||||||
|
(2320000000000, 'YardFacilityFreightTypes2320000000000'),
|
||||||
|
(2320000000000, 'SupportChatAttachments2320000000000'),
|
||||||
|
(2320000000000, 'AddRateYardScope2320000000000'),
|
||||||
|
(2330000000000, 'AddBookingCloseOffset2330000000000'),
|
||||||
|
(2340000000000, 'AddReverseWagonOrder2340000000000'),
|
||||||
|
(2340000000000, 'AddCargoTypeHasLashing2340000000000'),
|
||||||
|
(2350000000000, 'RefreshContractPricingArticles2350000000000'),
|
||||||
|
(2360000000000, 'RefreshContractPricingArticles2360000000000'),
|
||||||
|
(2370000000000, 'AddContainerUnitReturnFlag2370000000000'),
|
||||||
|
(2380000000000, 'AddTrainDeactivatedStatus2380000000000'),
|
||||||
|
(2390000000000, 'WidenYardCodeForSoftDeleteSuffix2390000000000'),
|
||||||
|
(2390000000000, 'SeedImportTrainNumbers2390000000000'),
|
||||||
|
(2400000000000, 'NormalizeCompanyRegions2400000000000'),
|
||||||
|
(2400000000000, 'AddCustomerTruckExitWeights2400000000000'),
|
||||||
|
(2410000000000, 'DropBookingApprovalWidenRoles2410000000000'),
|
||||||
|
(2420000000000, 'CreateContractDocumentRevisions2420000000000'),
|
||||||
|
(2430000000000, 'UniqueLocomotiveName2430000000000'),
|
||||||
|
(2430000000000, 'AddFileReviewStatus2430000000000'),
|
||||||
|
(2440000000000, 'AddHandoverEdrAssignment2440000000000'),
|
||||||
|
(2450000000000, 'DropActiveProfileTypeFromExternalProfiles2450000000000'),
|
||||||
|
(2460000000000, 'AddCacBankPaymentMethod2460000000000'),
|
||||||
|
(2470000000000, 'AddAcquisitionItemName2470000000000'),
|
||||||
|
(2480000000000, 'AddMaintenanceDueNotifiedAt2480000000000'),
|
||||||
|
(2800000000000, 'AddMaintenanceIntervals2800000000000'),
|
||||||
|
(2800000000001, 'AddSignatureToHandover2800000000001'),
|
||||||
|
(2810000000000, 'AddMaintenanceServiceItem2810000000000'),
|
||||||
|
(2820000000000, 'CustomsClearanceRouteScope2820000000000'),
|
||||||
|
(2820000000000, 'AddMileTonsQuantity2820000000000'),
|
||||||
|
(2830000000000, 'ReturnSurchargeRouteScope2830000000000'),
|
||||||
|
(2840000000000, 'AddTruckTypes2840000000000'),
|
||||||
|
(2850000000000, 'AddBookingDoubleHandling2850000000000'),
|
||||||
|
(2860000000000, 'DropClearanceFeePrepay2860000000000'),
|
||||||
|
(2860000000000, 'AddPerTruckDetentionWindow2860000000000'),
|
||||||
|
(2870000000000, 'CustomsClearancePerKind2870000000000'),
|
||||||
|
(2880000000000, 'LashingPerKind2880000000000'),
|
||||||
|
(2890000000000, 'LashingBulkOnlyPerDirection2890000000000'),
|
||||||
|
(2900000000000, 'LivestockPerItem2900000000000'),
|
||||||
|
(2910000000000, 'AddContractSignatureStamp2910000000000'),
|
||||||
|
(2920000000000, 'AddRevisionActorName2920000000000'),
|
||||||
|
(2930000000000, 'AddWagonTransferPartialFulfilment2930000000000'),
|
||||||
|
(2940000000000, 'AddFileVersionHistory2940000000000'),
|
||||||
|
(2950000000000, 'AddTransitAssigneeHandshake2950000000000'),
|
||||||
|
(2960000000000, 'AddContractHazardDeclaration2960000000000'),
|
||||||
|
(2970000000000, 'AddDoCollectionDates2970000000000'),
|
||||||
|
(2980000000000, 'AddBookingRequestCurrency2980000000000'),
|
||||||
|
(2990000000000, 'IndodeYardsAndCargoRouting2990000000000'),
|
||||||
|
(3000000000000, 'AddContractSuspension3000000000000'),
|
||||||
|
(3010000000000, 'AddBookingTransitAssignee3010000000000'),
|
||||||
|
(3020000000000, 'AddContractSubmittedAt3020000000000'),
|
||||||
|
(3030000000000, 'AddGlExchangeDocumentFields3030000000000'),
|
||||||
|
(3040000000000, 'AddTransitAgents3040000000000'),
|
||||||
|
(3050000000000, 'AddCbeBillPaymentMethod3050000000000'),
|
||||||
|
(3050000000000, 'MergeDuplicateSebetaYards3050000000000'),
|
||||||
|
(3060000000000, 'AddExportPaymentWindow3060000000000'),
|
||||||
|
(3070000000000, 'AddBulkTotalWeightTons3070000000000'),
|
||||||
|
(3080000000000, 'BackfillDireDawaMilestone3080000000000'),
|
||||||
|
(3090000000000, 'AddSavedSignatureStamp3090000000000'),
|
||||||
|
(3100000000000, 'PromoteDraftSchedulesToScheduled3100000000000'),
|
||||||
|
(3110000000000, 'AddYardToScheduleWagonAdjustmentLogs3110000000000'),
|
||||||
|
(3120000000000, 'AddApprovedAtToCompanies3120000000000'),
|
||||||
|
(3120000000000, 'AddCargoTypeItemsPerWagonMap3120000000000'),
|
||||||
|
(3130000000000, 'CreateCompanyRevisions3130000000000'),
|
||||||
|
(3140000000000, 'SyncBulkRateUnitsToCargoUom3140000000000'),
|
||||||
|
(3150000000000, 'CreateUserTradeAccess3150000000000'),
|
||||||
|
(3150000000000, 'FixFaydaAddressShape3150000000000'),
|
||||||
|
(3160000000000, 'FixPaymentPaidAtType3160000000000'),
|
||||||
|
(3170000000000, 'YardFacilityOriginDestination3170000000000'),
|
||||||
|
(3180000000000, 'PortYardFacilityRecords3180000000000'),
|
||||||
|
(3190000000000, 'AddCargoTypeTonsPerWagonMap3190000000000'),
|
||||||
|
(3200000000000, 'AddScheduleWindowRuleCustom3200000000000'),
|
||||||
|
(3210000000000, 'EmptyContainerReturnedBy3210000000000'),
|
||||||
|
(3220000000000, 'EmptyContainerReturnStatusHistory3220000000000'),
|
||||||
|
(3230000000000, 'SplitContractTemplatesByCustoms3230000000000'),
|
||||||
|
(3240000000000, 'CreateExchangeSettings3240000000000');
|
||||||
BIN
migrations-rows-backup-20260809.dump
Normal file
BIN
migrations-rows-backup-20260809.dump
Normal file
Binary file not shown.
Reference in New Issue
Block a user