mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Merge branch 'dev' of github.com:Tria-plc/edr-platform into freight_feature/usermanagement
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -52,3 +52,6 @@ RUNNING_LOCALLY.md
|
|||||||
|
|
||||||
# Generated per-shard compose file for the integration suite (it.mjs).
|
# Generated per-shard compose file for the integration suite (it.mjs).
|
||||||
integration/.it-shards.yaml
|
integration/.it-shards.yaml
|
||||||
|
branch_structure.json
|
||||||
|
temp_auto_push.bat
|
||||||
|
temp_interactive_push.bat
|
||||||
|
|||||||
@@ -1,5 +1,17 @@
|
|||||||
# Copy to .env for local/docker compose (not committed).
|
# Copy to .env for local/docker compose (not committed).
|
||||||
PORT=3001
|
PORT=3001
|
||||||
|
# @tria-plc/auditlog's client interceptor stamps every AuditLog row's
|
||||||
|
# `application` from this env var directly, bypassing MezgebModule.forRoot's
|
||||||
|
# applicationName option (package quirk). audit.controller.ts reads the same
|
||||||
|
# var when filtering reads, so this can be anything as long as it's set.
|
||||||
|
APPLICATION_NAME=freight-api
|
||||||
|
# Also required for @tria-plc/auditlog: its producer (AuditClientModule)
|
||||||
|
# reads the RMQ URL at package IMPORT time, before MezgebModule.forRoot's
|
||||||
|
# rmqUrl option ever runs, so only an env var reaches it — an in-code
|
||||||
|
# override is too late. Without this, audit events are silently dropped
|
||||||
|
# (no error, nothing published). Point it at whatever broker/vhost your
|
||||||
|
# RabbitMQ actually has a user provisioned on.
|
||||||
|
RABBITMQ_URL=amqp://localhost:5672
|
||||||
# GT06 GPS tracker TCP listener port (raw TCP, must be reachable by tracker SIMs). 0 disables.
|
# GT06 GPS tracker TCP listener port (raw TCP, must be reachable by tracker SIMs). 0 disables.
|
||||||
GT06_TCP_PORT=5023
|
GT06_TCP_PORT=5023
|
||||||
DB_HOST=localhost
|
DB_HOST=localhost
|
||||||
|
|||||||
@@ -59,6 +59,7 @@
|
|||||||
"@nestjs/typeorm": "^11.0.1",
|
"@nestjs/typeorm": "^11.0.1",
|
||||||
"@nestjs/websockets": "^11.1.27",
|
"@nestjs/websockets": "^11.1.27",
|
||||||
"@tria-plc/api-common": "file:../../local-packages/tria-plc-api-common-1.6.0.tgz",
|
"@tria-plc/api-common": "file:../../local-packages/tria-plc-api-common-1.6.0.tgz",
|
||||||
|
"@tria-plc/auditlog": "file:../../local-packages/tria-plc-auditlog-1.1.2.tgz",
|
||||||
"@tria-plc/iamapi-common": "file:../../local-packages/tria-plc-iamapi-common-1.0.0.tgz",
|
"@tria-plc/iamapi-common": "file:../../local-packages/tria-plc-iamapi-common-1.0.0.tgz",
|
||||||
"amqp-connection-manager": "^5.0.0",
|
"amqp-connection-manager": "^5.0.0",
|
||||||
"amqplib": "^2.0.1",
|
"amqplib": "^2.0.1",
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
import { IamBaselineSeeder, IamSeedModule } from "@edr/iam-seed";
|
import { IamBaselineSeeder, IamSeedModule } from "@edr/iam-seed";
|
||||||
import { IamModule } from "@tria-plc/iamapi-common";
|
import { IamModule } from "@tria-plc/iamapi-common";
|
||||||
import { SharedAuthModule } from "@tria-plc/api-common/modules/auth/shared-auth.module";
|
import { SharedAuthModule } from "@tria-plc/api-common/modules/auth/shared-auth.module";
|
||||||
|
import { MezgebModule } from "@tria-plc/auditlog";
|
||||||
|
|
||||||
import appConfig from "./config/app.config";
|
import appConfig from "./config/app.config";
|
||||||
import databaseConfig from "./config/database.config";
|
import databaseConfig from "./config/database.config";
|
||||||
@@ -102,13 +103,19 @@ import { ProcurementModule } from "./modules/procurement/procurement.module";
|
|||||||
import { GpsTrackingModule } from "./modules/gps-tracking/gps-tracking.module";
|
import { GpsTrackingModule } from "./modules/gps-tracking/gps-tracking.module";
|
||||||
import { FirstMileModule } from "./modules/first-mile/first-mile.module";
|
import { FirstMileModule } from "./modules/first-mile/first-mile.module";
|
||||||
import { LastMileModule } from "./modules/last-mile/last-mile.module";
|
import { LastMileModule } from "./modules/last-mile/last-mile.module";
|
||||||
|
import { LastMileRequestsModule } from "./modules/last-mile-requests/last-mile-requests.module";
|
||||||
import { InterchangeDocumentsModule } from "./modules/interchange-documents/interchange-documents.module";
|
import { InterchangeDocumentsModule } from "./modules/interchange-documents/interchange-documents.module";
|
||||||
import { ImportOperationsModule } from "./modules/import-operations/import-operations.module";
|
import { ImportOperationsModule } from "./modules/import-operations/import-operations.module";
|
||||||
import { AiModule } from "./modules/ai/ai.module";
|
import { AiModule } from "./modules/ai/ai.module";
|
||||||
|
import { AuditModule } from "./modules/audit/audit.module";
|
||||||
import { LoggerMiddleware } from "./logger.middleware";
|
import { LoggerMiddleware } from "./logger.middleware";
|
||||||
import { LoginAudienceMiddleware } from "./modules/auth/login-audience.middleware";
|
import { LoginAudienceMiddleware } from "./modules/auth/login-audience.middleware";
|
||||||
import { PositionTypePermissionsCache } from "./common/position-type-permissions.cache";
|
import { PositionTypePermissionsCache } from "./common/position-type-permissions.cache";
|
||||||
|
|
||||||
|
if (!process.env.APPLICATION_NAME) {
|
||||||
|
process.env.APPLICATION_NAME = "freight";
|
||||||
|
}
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
ConfigModule.forRoot({
|
ConfigModule.forRoot({
|
||||||
@@ -156,6 +163,19 @@ import { PositionTypePermissionsCache } from "./common/position-type-permissions
|
|||||||
return dataSource;
|
return dataSource;
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
// Request + entity-level audit logging over RabbitMQ (@tria-plc/auditlog).
|
||||||
|
// Must come after TypeOrmModule above so it picks up this app's DataSource.
|
||||||
|
// rmqUrl falls back the same way notifications.module.ts's RABBITMQ_URL
|
||||||
|
// does: the dev broker only provisions the `edr` user on the `payment`
|
||||||
|
// vhost (docker-compose's RABBITMQ_DEFAULT_USER/VHOST), so an unset
|
||||||
|
// RABBITMQ_URL must land there too, not on guest@'/' (403 ACCESS_REFUSED).
|
||||||
|
MezgebModule.forRoot({
|
||||||
|
applicationName: "freight-api",
|
||||||
|
rmqUrl:
|
||||||
|
process.env.RABBITMQ_URL ??
|
||||||
|
process.env.PAYMENT_RABBITMQ_URL ??
|
||||||
|
"amqp://localhost:5672",
|
||||||
|
}),
|
||||||
SharedAuthModule,
|
SharedAuthModule,
|
||||||
IamModule.forRoot({
|
IamModule.forRoot({
|
||||||
applications: [EDR_FREIGHT_APPLICATION],
|
applications: [EDR_FREIGHT_APPLICATION],
|
||||||
@@ -223,11 +243,13 @@ import { PositionTypePermissionsCache } from "./common/position-type-permissions
|
|||||||
GpsTrackingModule,
|
GpsTrackingModule,
|
||||||
FirstMileModule,
|
FirstMileModule,
|
||||||
LastMileModule,
|
LastMileModule,
|
||||||
|
LastMileRequestsModule,
|
||||||
InterchangeDocumentsModule,
|
InterchangeDocumentsModule,
|
||||||
ImportOperationsModule,
|
ImportOperationsModule,
|
||||||
VerifaydaModule,
|
VerifaydaModule,
|
||||||
FleetHistoryModule,
|
FleetHistoryModule,
|
||||||
AiModule,
|
AiModule,
|
||||||
|
AuditModule,
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
EdrOrgSeeder,
|
EdrOrgSeeder,
|
||||||
|
|||||||
163
apps/edr-freight-api/src/common/last-mile-charge.util.spec.ts
Normal file
163
apps/edr-freight-api/src/common/last-mile-charge.util.spec.ts
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
import { computeLastMileCharge } from './last-mile-charge.util';
|
||||||
|
import type { Rate } from '../modules/rule-engine/entities/rate.entity';
|
||||||
|
|
||||||
|
const rate = (over: Partial<Rate>): Rate =>
|
||||||
|
({
|
||||||
|
appliesTo: 'LAST_MILE',
|
||||||
|
status: 'LIVE',
|
||||||
|
currency: 'ETB',
|
||||||
|
trigger: 'ALWAYS',
|
||||||
|
rateType: 'LAST_MILE',
|
||||||
|
...over,
|
||||||
|
}) as Rate;
|
||||||
|
|
||||||
|
const band20a = rate({
|
||||||
|
rateUnit: 'PER_KM',
|
||||||
|
rateValue: 1800,
|
||||||
|
minKm: 0,
|
||||||
|
maxKm: 30,
|
||||||
|
containerType: { sizeFt: 20 } as Rate['containerType'],
|
||||||
|
});
|
||||||
|
const band20b = rate({
|
||||||
|
rateUnit: 'PER_KM',
|
||||||
|
rateValue: 1500,
|
||||||
|
minKm: 30,
|
||||||
|
maxKm: null,
|
||||||
|
containerType: { sizeFt: 20 } as Rate['containerType'],
|
||||||
|
});
|
||||||
|
const band40a = rate({
|
||||||
|
rateUnit: 'PER_KM',
|
||||||
|
rateValue: 2200,
|
||||||
|
minKm: 0,
|
||||||
|
maxKm: 30,
|
||||||
|
containerType: { sizeFt: 40 } as Rate['containerType'],
|
||||||
|
});
|
||||||
|
const bulkRate = rate({ rateUnit: 'PER_TON_KM', rateValue: 25 });
|
||||||
|
|
||||||
|
describe('computeLastMileCharge', () => {
|
||||||
|
it('prices containers per band × size × quantity', () => {
|
||||||
|
const charge = computeLastMileCharge({
|
||||||
|
freightType: 'CONTAINER',
|
||||||
|
tons: 0,
|
||||||
|
km: 13,
|
||||||
|
containers: [
|
||||||
|
{ sizeLabel: '20DC', qty: 5 },
|
||||||
|
{ sizeLabel: '40HC', qty: 1 },
|
||||||
|
],
|
||||||
|
liveRates: [band20a, band20b, band40a],
|
||||||
|
});
|
||||||
|
// 13 × 1800 × 5 + 13 × 2200 × 1
|
||||||
|
expect(charge).toMatchObject({ mode: 'CONTAINER', total: 117000 + 28600, currency: 'ETB' });
|
||||||
|
expect(charge!.lines).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('band boundary is half-open: km = 30 falls in the 30+ band', () => {
|
||||||
|
const charge = computeLastMileCharge({
|
||||||
|
freightType: 'CONTAINER',
|
||||||
|
tons: 0,
|
||||||
|
km: 30,
|
||||||
|
containers: [{ sizeLabel: '20DC', qty: 1 }],
|
||||||
|
liveRates: [band20a, band20b],
|
||||||
|
});
|
||||||
|
expect(charge!.lines[0].unitRate).toBe(1500);
|
||||||
|
expect(charge!.total).toBe(30 * 1500);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null when a size has no matching band', () => {
|
||||||
|
const charge = computeLastMileCharge({
|
||||||
|
freightType: 'CONTAINER',
|
||||||
|
tons: 0,
|
||||||
|
km: 50,
|
||||||
|
containers: [{ sizeLabel: '40HC', qty: 2 }],
|
||||||
|
liveRates: [band40a], // 40ft only covers 0–30
|
||||||
|
});
|
||||||
|
expect(charge).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prices bulk as tons × km × rate', () => {
|
||||||
|
const charge = computeLastMileCharge({
|
||||||
|
freightType: 'BULK',
|
||||||
|
tons: 60,
|
||||||
|
km: 26,
|
||||||
|
containers: [],
|
||||||
|
liveRates: [bulkRate],
|
||||||
|
});
|
||||||
|
expect(charge).toMatchObject({ mode: 'BULK', total: 60 * 26 * 25, currency: 'ETB' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('picks the bulk rate whose distance band holds the km (half-open boundary)', () => {
|
||||||
|
const bulkNear = rate({ rateUnit: 'PER_TON_KM', rateValue: 30, minKm: 0, maxKm: 30 });
|
||||||
|
const bulkFar = rate({ rateUnit: 'PER_TON_KM', rateValue: 22, minKm: 30, maxKm: null });
|
||||||
|
const near = computeLastMileCharge({
|
||||||
|
freightType: 'BULK',
|
||||||
|
tons: 10,
|
||||||
|
km: 12,
|
||||||
|
containers: [],
|
||||||
|
liveRates: [bulkNear, bulkFar],
|
||||||
|
});
|
||||||
|
expect(near).toMatchObject({ mode: 'BULK', total: 10 * 12 * 30 });
|
||||||
|
const boundary = computeLastMileCharge({
|
||||||
|
freightType: 'BULK',
|
||||||
|
tons: 10,
|
||||||
|
km: 30,
|
||||||
|
containers: [],
|
||||||
|
liveRates: [bulkNear, bulkFar],
|
||||||
|
});
|
||||||
|
expect(boundary).toMatchObject({ total: 10 * 30 * 22 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('bulk falls back to the legacy bandless rate when no band holds the km, null when nothing covers it', () => {
|
||||||
|
const bulkNear = rate({ rateUnit: 'PER_TON_KM', rateValue: 30, minKm: 0, maxKm: 30 });
|
||||||
|
const fallback = computeLastMileCharge({
|
||||||
|
freightType: 'BULK',
|
||||||
|
tons: 10,
|
||||||
|
km: 50,
|
||||||
|
containers: [],
|
||||||
|
liveRates: [bulkNear, bulkRate], // bulkRate has no band
|
||||||
|
});
|
||||||
|
expect(fallback).toMatchObject({ total: 10 * 50 * 25 });
|
||||||
|
expect(
|
||||||
|
computeLastMileCharge({
|
||||||
|
freightType: 'BULK',
|
||||||
|
tons: 10,
|
||||||
|
km: 50,
|
||||||
|
containers: [],
|
||||||
|
liveRates: [bulkNear],
|
||||||
|
}),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null on mixed currencies, unknown km, and uncovered freight types', () => {
|
||||||
|
const usd40 = rate({ ...band40a, currency: 'USD' });
|
||||||
|
expect(
|
||||||
|
computeLastMileCharge({
|
||||||
|
freightType: 'CONTAINER',
|
||||||
|
tons: 0,
|
||||||
|
km: 10,
|
||||||
|
containers: [
|
||||||
|
{ sizeLabel: '20DC', qty: 1 },
|
||||||
|
{ sizeLabel: '40HC', qty: 1 },
|
||||||
|
],
|
||||||
|
liveRates: [band20a, usd40],
|
||||||
|
}),
|
||||||
|
).toBeNull();
|
||||||
|
expect(
|
||||||
|
computeLastMileCharge({
|
||||||
|
freightType: 'BULK',
|
||||||
|
tons: 10,
|
||||||
|
km: 0,
|
||||||
|
containers: [],
|
||||||
|
liveRates: [bulkRate],
|
||||||
|
}),
|
||||||
|
).toBeNull();
|
||||||
|
expect(
|
||||||
|
computeLastMileCharge({
|
||||||
|
freightType: 'BREAK_BULK',
|
||||||
|
tons: 10,
|
||||||
|
km: 10,
|
||||||
|
containers: [],
|
||||||
|
liveRates: [bulkRate],
|
||||||
|
}),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
212
apps/edr-freight-api/src/common/last-mile-charge.util.ts
Normal file
212
apps/edr-freight-api/src/common/last-mile-charge.util.ts
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
import type { DataSource } from 'typeorm';
|
||||||
|
import type { Rate } from '../modules/rule-engine/entities/rate.entity';
|
||||||
|
import { bookingContainerSizes } from './truck-load.util';
|
||||||
|
|
||||||
|
/** One priced line of a rule-based last-mile charge. */
|
||||||
|
export interface LastMileChargeLine {
|
||||||
|
description: string;
|
||||||
|
quantity: number;
|
||||||
|
unitRate: number;
|
||||||
|
amount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A fully-resolved rule-based last-mile charge. */
|
||||||
|
export interface LastMileCharge {
|
||||||
|
mode: 'BULK' | 'CONTAINER';
|
||||||
|
total: number;
|
||||||
|
currency: string;
|
||||||
|
lines: LastMileChargeLine[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What the last-mile leg is hauling, in the shape the rate rules price. */
|
||||||
|
export interface LastMileShipmentShape {
|
||||||
|
freightType: string | null;
|
||||||
|
tons: number;
|
||||||
|
containers: Array<{ sizeLabel: string; qty: number }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const round2 = (n: number): number => Math.round(n * 100) / 100;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Price a last-mile leg off the LIVE rate rules. Pure — pass the live rates in.
|
||||||
|
*
|
||||||
|
* BULK: the PER_TON_KM rate whose distance band holds the km (a legacy
|
||||||
|
* bandless row — NULL minKm — is the fallback and prices every distance) →
|
||||||
|
* price = tons × km × rate.
|
||||||
|
* CONTAINER: per container size, the PER_KM rate whose distance band holds the
|
||||||
|
* km → price = km × rate × quantity, summed across sizes.
|
||||||
|
* Bands are half-open [minKm, maxKm), NULL maxKm = open-ended.
|
||||||
|
*
|
||||||
|
* Returns null whenever the rules don't fully cover the shipment (no rate, a
|
||||||
|
* container size without a matching band, mixed currencies, km/tons unknown) —
|
||||||
|
* callers keep their existing pricing as the fallback. Never throws.
|
||||||
|
*/
|
||||||
|
export function computeLastMileCharge(input: {
|
||||||
|
freightType: string | null;
|
||||||
|
tons: number;
|
||||||
|
km: number;
|
||||||
|
containers: Array<{ sizeLabel: string; qty: number }>;
|
||||||
|
/** LIVE rates with the containerType relation loaded (findLiveRatesDetailed). */
|
||||||
|
liveRates: Rate[];
|
||||||
|
}): LastMileCharge | null {
|
||||||
|
const { freightType, tons, km, containers, liveRates } = input;
|
||||||
|
if (!km || km <= 0) return null;
|
||||||
|
|
||||||
|
const candidates = liveRates.filter(
|
||||||
|
(rate) => rate.appliesTo === 'LAST_MILE' && rate.status === 'LIVE',
|
||||||
|
);
|
||||||
|
|
||||||
|
if (freightType === 'BULK') {
|
||||||
|
if (!tons || tons <= 0) return null;
|
||||||
|
const bulkRates = candidates.filter((r) => r.rateUnit === 'PER_TON_KM');
|
||||||
|
const rate =
|
||||||
|
bulkRates.find(
|
||||||
|
(r) =>
|
||||||
|
r.minKm !== null &&
|
||||||
|
r.minKm !== undefined &&
|
||||||
|
Number(r.minKm) <= km &&
|
||||||
|
(r.maxKm === null || r.maxKm === undefined || km < Number(r.maxKm)),
|
||||||
|
) ?? bulkRates.find((r) => r.minKm === null || r.minKm === undefined);
|
||||||
|
if (!rate) return null;
|
||||||
|
const unitRate = Number(rate.rateValue);
|
||||||
|
const amount = round2(tons * km * unitRate);
|
||||||
|
return {
|
||||||
|
mode: 'BULK',
|
||||||
|
total: amount,
|
||||||
|
currency: rate.currency,
|
||||||
|
lines: [
|
||||||
|
{
|
||||||
|
description: `Last-mile bulk delivery — ${tons} t × ${km} km × ${unitRate}/t·km`,
|
||||||
|
quantity: tons,
|
||||||
|
unitRate,
|
||||||
|
amount,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (freightType === 'CONTAINER') {
|
||||||
|
if (!containers.length) return null;
|
||||||
|
const lines: LastMileChargeLine[] = [];
|
||||||
|
const currencies = new Set<string>();
|
||||||
|
for (const group of containers) {
|
||||||
|
const rate = candidates.find(
|
||||||
|
(r) =>
|
||||||
|
r.rateUnit === 'PER_KM' &&
|
||||||
|
r.minKm !== null &&
|
||||||
|
r.minKm !== undefined &&
|
||||||
|
r.containerType?.sizeFt !== null &&
|
||||||
|
r.containerType?.sizeFt !== undefined &&
|
||||||
|
group.sizeLabel.includes(String(r.containerType.sizeFt)) &&
|
||||||
|
Number(r.minKm) <= km &&
|
||||||
|
(r.maxKm === null || r.maxKm === undefined || km < Number(r.maxKm)),
|
||||||
|
);
|
||||||
|
// A size the rules don't cover means the rule set can't price this job.
|
||||||
|
if (!rate) return null;
|
||||||
|
const unitRate = Number(rate.rateValue);
|
||||||
|
const amount = round2(km * unitRate * group.qty);
|
||||||
|
currencies.add(rate.currency);
|
||||||
|
lines.push({
|
||||||
|
description: `Last-mile delivery — ${group.qty} × ${group.sizeLabel} container, ${km} km @ ${unitRate}/km`,
|
||||||
|
quantity: group.qty,
|
||||||
|
unitRate,
|
||||||
|
amount,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// A charge can't mix birr and dollar lines on one invoice.
|
||||||
|
if (currencies.size !== 1) return null;
|
||||||
|
return {
|
||||||
|
mode: 'CONTAINER',
|
||||||
|
total: round2(lines.reduce((sum, line) => sum + line.amount, 0)),
|
||||||
|
currency: [...currencies][0],
|
||||||
|
lines,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rule-based charge for an operational last-mile record: prices what its
|
||||||
|
* trucks actually haul (last_mile_vehicle_containers / weighed net tons)
|
||||||
|
* against the given km. Shared by setDistances (writes remainingPayment) and
|
||||||
|
* DELIVERY_FEE invoicing so the two never disagree on the math. Null = the
|
||||||
|
* rules don't cover this job — callers keep the per-vehicle price/km path.
|
||||||
|
*/
|
||||||
|
export async function ruleBasedLastMileCharge(
|
||||||
|
dataSource: DataSource,
|
||||||
|
liveRates: Rate[],
|
||||||
|
lastMileId: string,
|
||||||
|
km: number,
|
||||||
|
): Promise<LastMileCharge | null> {
|
||||||
|
if (!km || km <= 0) return null;
|
||||||
|
const [record]: Array<{ bookingId: string }> = await dataSource.query(
|
||||||
|
`SELECT booking_id AS "bookingId"
|
||||||
|
FROM freight.last_mile
|
||||||
|
WHERE id = $1 AND deleted_at IS NULL`,
|
||||||
|
[lastMileId],
|
||||||
|
);
|
||||||
|
if (!record) return null;
|
||||||
|
|
||||||
|
const containerRows: Array<{ containerNumber: string }> = await dataSource.query(
|
||||||
|
`SELECT container_number AS "containerNumber"
|
||||||
|
FROM freight.last_mile_vehicle_containers
|
||||||
|
WHERE last_mile_id = $1 AND deleted_at IS NULL`,
|
||||||
|
[lastMileId],
|
||||||
|
);
|
||||||
|
const shape = await lastMileShipmentShape(
|
||||||
|
dataSource,
|
||||||
|
record.bookingId,
|
||||||
|
containerRows.map((r) => r.containerNumber),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Bulk: bill the weighed tonnage on this record's trucks when known,
|
||||||
|
// falling back to the booking's declared VGM total.
|
||||||
|
const [tonsRow]: Array<{ tons: string | null }> = await dataSource.query(
|
||||||
|
`SELECT SUM(net_weight_tons) AS "tons"
|
||||||
|
FROM freight.last_mile_vehicle_assignments
|
||||||
|
WHERE last_mile_id = $1 AND deleted_at IS NULL`,
|
||||||
|
[lastMileId],
|
||||||
|
);
|
||||||
|
const weighedTons = Number(tonsRow?.tons ?? 0);
|
||||||
|
|
||||||
|
return computeLastMileCharge({
|
||||||
|
...shape,
|
||||||
|
tons: weighedTons > 0 ? weighedTons : shape.tons,
|
||||||
|
km,
|
||||||
|
liveRates,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load a booking's shipment shape for the charge resolver: freight type, bulk
|
||||||
|
* tonnage, and the container numbers grouped into size × quantity.
|
||||||
|
*/
|
||||||
|
export async function lastMileShipmentShape(
|
||||||
|
dataSource: DataSource,
|
||||||
|
bookingId: string,
|
||||||
|
containerNumbers: string[],
|
||||||
|
): Promise<LastMileShipmentShape> {
|
||||||
|
const [booking]: Array<{ freightType: string | null; tons: string | null }> =
|
||||||
|
await dataSource.query(
|
||||||
|
`SELECT freight_type AS "freightType", cargo_total_weight_vgm AS "tons"
|
||||||
|
FROM freight.bookings
|
||||||
|
WHERE id = $1 AND deleted_at IS NULL`,
|
||||||
|
[bookingId],
|
||||||
|
);
|
||||||
|
const sizes = await bookingContainerSizes(
|
||||||
|
dataSource,
|
||||||
|
bookingId,
|
||||||
|
containerNumbers.map((n) => n.trim().toUpperCase()),
|
||||||
|
);
|
||||||
|
const bySize = new Map<string, number>();
|
||||||
|
for (const size of sizes) {
|
||||||
|
if (!size) continue;
|
||||||
|
bySize.set(size, (bySize.get(size) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
freightType: booking?.freightType ?? null,
|
||||||
|
tons: Number(booking?.tons ?? 0),
|
||||||
|
containers: [...bySize.entries()].map(([sizeLabel, qty]) => ({ sizeLabel, qty })),
|
||||||
|
};
|
||||||
|
}
|
||||||
17
apps/edr-freight-api/src/common/mile-distance.util.spec.ts
Normal file
17
apps/edr-freight-api/src/common/mile-distance.util.spec.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { haversineKm } from './mile-distance.util';
|
||||||
|
|
||||||
|
describe('haversineKm', () => {
|
||||||
|
it('is zero for the same point', () => {
|
||||||
|
expect(haversineKm(8.9, 38.6, 8.9, 38.6)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('matches one degree of longitude at the equator (~111.19 km)', () => {
|
||||||
|
expect(haversineKm(0, 0, 0, 1)).toBeCloseTo(111.19, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Sebeta yard → Indode yard is roughly 26 km', () => {
|
||||||
|
const km = haversineKm(8.9096, 38.636, 8.7386, 38.7913);
|
||||||
|
expect(km).toBeGreaterThan(20);
|
||||||
|
expect(km).toBeLessThan(35);
|
||||||
|
});
|
||||||
|
});
|
||||||
48
apps/edr-freight-api/src/common/mile-distance.util.ts
Normal file
48
apps/edr-freight-api/src/common/mile-distance.util.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { DataSource } from 'typeorm';
|
||||||
|
|
||||||
|
/** Great-circle distance in km between two WGS84 points (haversine). */
|
||||||
|
export function haversineKm(lat1: number, lng1: number, lat2: number, lng2: number): number {
|
||||||
|
const toRad = (d: number) => (d * Math.PI) / 180;
|
||||||
|
const dLat = toRad(lat2 - lat1);
|
||||||
|
const dLng = toRad(lng2 - lng1);
|
||||||
|
const a =
|
||||||
|
Math.sin(dLat / 2) ** 2 + Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLng / 2) ** 2;
|
||||||
|
return 2 * 6371 * Math.asin(Math.sqrt(a));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Estimated road-leg distance for a booking's first/last mile: straight-line km
|
||||||
|
* from the yard (freight.yard_locations) to the customer's pickup/delivery GPS
|
||||||
|
* point on the booking. FIRST = origin yard → pickup point, LAST = destination
|
||||||
|
* yard → delivery point. Null when either end has no coordinates.
|
||||||
|
*
|
||||||
|
* ponytail: haversine straight-line, not road routing — plug a routing API in
|
||||||
|
* here if real road km is ever required.
|
||||||
|
*/
|
||||||
|
export async function estimateMileKm(
|
||||||
|
dataSource: DataSource,
|
||||||
|
bookingId: string,
|
||||||
|
mile: 'FIRST' | 'LAST',
|
||||||
|
): Promise<number | null> {
|
||||||
|
const [row] = await dataSource.query(
|
||||||
|
mile === 'LAST'
|
||||||
|
? `SELECT b.last_mile_delivery_lat AS lat, b.last_mile_delivery_lng AS lng,
|
||||||
|
l.latitude AS yard_lat, l.longitude AS yard_lng
|
||||||
|
FROM freight.bookings b
|
||||||
|
LEFT JOIN freight.yard_locations l
|
||||||
|
ON l.yard_id = b.destination_yard_id AND l.deleted_at IS NULL
|
||||||
|
WHERE b.id = $1 AND b.deleted_at IS NULL`
|
||||||
|
: `SELECT b.first_mile_pickup_lat AS lat, b.first_mile_pickup_lng AS lng,
|
||||||
|
l.latitude AS yard_lat, l.longitude AS yard_lng
|
||||||
|
FROM freight.bookings b
|
||||||
|
LEFT JOIN freight.yard_locations l
|
||||||
|
ON l.yard_id = b.origin_yard_id AND l.deleted_at IS NULL
|
||||||
|
WHERE b.id = $1 AND b.deleted_at IS NULL`,
|
||||||
|
[bookingId],
|
||||||
|
);
|
||||||
|
if (!row || row.lat == null || row.lng == null || row.yard_lat == null || row.yard_lng == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const km = haversineKm(Number(row.yard_lat), Number(row.yard_lng), Number(row.lat), Number(row.lng));
|
||||||
|
return Math.round(km * 100) / 100;
|
||||||
|
}
|
||||||
@@ -56,6 +56,11 @@ import { EmployeePositionActivePeriod } from "@tria-plc/iamapi-common/entities/i
|
|||||||
import { UnitConfiguration } from "@tria-plc/iamapi-common/entities/iam/organization-structure/unit-configuration.entity";
|
import { UnitConfiguration } from "@tria-plc/iamapi-common/entities/iam/organization-structure/unit-configuration.entity";
|
||||||
import { Site } from "@tria-plc/iamapi-common/entities/iam/site/site.entity";
|
import { Site } from "@tria-plc/iamapi-common/entities/iam/site/site.entity";
|
||||||
import { SiteSetting } from "@tria-plc/iamapi-common/entities/iam/site/site-setting.entity";
|
import { SiteSetting } from "@tria-plc/iamapi-common/entities/iam/site/site-setting.entity";
|
||||||
|
import { AuditLog, AuditLogCommand } from "@tria-plc/auditlog";
|
||||||
|
|
||||||
|
// @tria-plc/auditlog's entities live in node_modules, same as the iam ones —
|
||||||
|
// the glob below only matches this app's own src/**/*.entity.ts.
|
||||||
|
const auditEntities = [AuditLog, AuditLogCommand];
|
||||||
|
|
||||||
const iamEntities = [
|
const iamEntities = [
|
||||||
UnitSetting,
|
UnitSetting,
|
||||||
@@ -177,7 +182,11 @@ export function buildDataSourceOptions(): DataSourceOptions {
|
|||||||
return {
|
return {
|
||||||
...buildConnectionOptions(),
|
...buildConnectionOptions(),
|
||||||
schema: "public",
|
schema: "public",
|
||||||
entities: [__dirname + "/../**/*.entity.{ts,js}", ...iamEntities],
|
entities: [
|
||||||
|
__dirname + "/../**/*.entity.{ts,js}",
|
||||||
|
...iamEntities,
|
||||||
|
...auditEntities,
|
||||||
|
],
|
||||||
migrations: [],
|
migrations: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ const UNIT_LABELS: Record<string, string> = {
|
|||||||
PER_TON: 'per ton',
|
PER_TON: 'per ton',
|
||||||
PER_CONTAINER: 'per container',
|
PER_CONTAINER: 'per container',
|
||||||
PER_KM: 'per km',
|
PER_KM: 'per km',
|
||||||
|
PER_TON_KM: 'per ton per km',
|
||||||
PER_INVOICE: 'per invoice',
|
PER_INVOICE: 'per invoice',
|
||||||
FLAT: 'flat',
|
FLAT: 'flat',
|
||||||
};
|
};
|
||||||
|
|||||||
114
apps/edr-freight-api/src/contracts/templates/last-mile.hbs
Normal file
114
apps/edr-freight-api/src/contracts/templates/last-mile.hbs
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<title>Last-Mile Delivery Contract — {{bookingReference}}</title>
|
||||||
|
<style>
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body { font-family: 'Helvetica Neue', Arial, sans-serif; color: #1a1a1a; margin: 0; font-size: 12px; line-height: 1.5; }
|
||||||
|
main { padding: 32px 40px; }
|
||||||
|
.brand-row { display: flex; align-items: center; gap: 14px; border-bottom: 3px solid #1a5632; padding-bottom: 14px; }
|
||||||
|
.logo-mark { background: #1a5632; color: #fff; font-weight: 700; font-size: 18px; padding: 10px 14px; border-radius: 6px; }
|
||||||
|
.kicker { margin: 0; font-weight: 700; }
|
||||||
|
.muted { margin: 0; color: #666; }
|
||||||
|
h1 { font-size: 20px; margin: 24px 0 4px; }
|
||||||
|
h2 { font-size: 14px; margin: 26px 0 8px; color: #1a5632; border-bottom: 1px solid #d8d8d8; padding-bottom: 4px; }
|
||||||
|
table { width: 100%; border-collapse: collapse; margin-top: 8px; }
|
||||||
|
th, td { border: 1px solid #d8d8d8; padding: 6px 10px; text-align: left; vertical-align: top; }
|
||||||
|
th { background: #f2f6f3; width: 32%; font-weight: 600; }
|
||||||
|
.rates th { width: auto; }
|
||||||
|
.rates .amount { text-align: right; white-space: nowrap; }
|
||||||
|
.rates tfoot td { font-weight: 700; background: #f2f6f3; }
|
||||||
|
.terms p { margin: 6px 0; }
|
||||||
|
.sig-grid { display: flex; gap: 24px; margin-top: 18px; }
|
||||||
|
.sig-card { flex: 1; border: 1px solid #d8d8d8; border-radius: 6px; padding: 14px; min-height: 130px; }
|
||||||
|
.sig-card h3 { margin: 0 0 8px; font-size: 12px; color: #1a5632; }
|
||||||
|
.sig-card img { max-height: 60px; max-width: 100%; }
|
||||||
|
.sig-line { border-top: 1px solid #999; margin-top: 40px; padding-top: 4px; color: #666; }
|
||||||
|
.consent { margin-top: 6px; font-style: italic; color: #444; }
|
||||||
|
.pending { color: #999; font-style: italic; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main>
|
||||||
|
<div class="brand-row">
|
||||||
|
<div class="logo-mark">EDR</div>
|
||||||
|
<div>
|
||||||
|
<p class="kicker">Ethio-Djibouti Standard Gauge Railway Share Company</p>
|
||||||
|
<p class="muted">Last-Mile Delivery Contract</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1>Last-Mile Delivery Contract</h1>
|
||||||
|
<p class="muted">Booking {{bookingReference}} — {{companyName}}</p>
|
||||||
|
|
||||||
|
<h2>Shipment Details</h2>
|
||||||
|
<table>
|
||||||
|
<tr><th>Client</th><td>{{companyName}}</td></tr>
|
||||||
|
<tr><th>Booking Reference</th><td>{{bookingReference}}</td></tr>
|
||||||
|
{{#if containerCount}}
|
||||||
|
<tr><th>Number of Containers</th><td>{{containerCount}}</td></tr>
|
||||||
|
<tr><th>Containers</th><td>{{containerList}}</td></tr>
|
||||||
|
{{/if}}
|
||||||
|
{{#if cargoDescription}}
|
||||||
|
<tr><th>Cargo Description</th><td>{{cargoDescription}}</td></tr>
|
||||||
|
{{/if}}
|
||||||
|
{{#if deliveryAddress}}
|
||||||
|
<tr><th>Delivery Address</th><td>{{deliveryAddress}}</td></tr>
|
||||||
|
{{/if}}
|
||||||
|
{{#if trainDepartureDate}}
|
||||||
|
<tr><th>Train Departure from Djibouti</th><td>{{trainDepartureDate}}</td></tr>
|
||||||
|
{{/if}}
|
||||||
|
<tr><th>Last-Mile Delivery Date</th><td>{{deliveryDate}}</td></tr>
|
||||||
|
<tr><th>Request Date</th><td>{{requestDate}}</td></tr>
|
||||||
|
<tr><th>Approval Date</th><td>{{approvalDate}}</td></tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h2>Rates</h2>
|
||||||
|
<table class="rates">
|
||||||
|
<thead>
|
||||||
|
<tr><th>Description</th><th class="amount">Amount{{#if currency}} ({{currency}}){{/if}}</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{{#each rateLines}}
|
||||||
|
<tr><td>{{description}}</td><td class="amount">{{amount}}</td></tr>
|
||||||
|
{{/each}}
|
||||||
|
{{#if estimatedKm}}
|
||||||
|
<tr><td class="muted">Estimated distance</td><td class="amount">{{estimatedKm}} km</td></tr>
|
||||||
|
{{/if}}
|
||||||
|
</tbody>
|
||||||
|
<tfoot>
|
||||||
|
<tr><td>Advance payable on signing</td><td class="amount">{{advanceAmount}}{{#if currency}} {{currency}}{{/if}}</td></tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h2>Terms</h2>
|
||||||
|
<div class="terms">
|
||||||
|
<p>1. The Service Provider shall deliver the goods identified above from the arrival yard to the Client's delivery address on or about the last-mile delivery date stated above.</p>
|
||||||
|
<p>2. The Client shall pay the advance stated above upon signing this contract. The final delivery fee is computed on completion per the Service Provider's published last-mile rates and actual distance.</p>
|
||||||
|
<p>3. The Client shall ensure access and receipt of the goods at the delivery address. Waiting time and truck detention beyond free time may incur additional charges per the applicable tariff.</p>
|
||||||
|
<p>4. This contract is governed by the laws applicable to the Ethio-Djibouti Standard Gauge Railway Share Company's freight services.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>Signatures</h2>
|
||||||
|
<div class="sig-grid">
|
||||||
|
<div class="sig-card">
|
||||||
|
<h3>Client</h3>
|
||||||
|
{{#if signature}}
|
||||||
|
<img src="{{signature.imageUrl}}" alt="Customer signature" />
|
||||||
|
<div class="sig-line">{{signature.signerDisplayName}} — signed {{signature.signedAt}}</div>
|
||||||
|
{{#if signature.consentText}}<div class="consent">"{{signature.consentText}}"</div>{{/if}}
|
||||||
|
{{else}}
|
||||||
|
<p class="pending">Awaiting customer signature.</p>
|
||||||
|
<div class="sig-line">Name, signature & date</div>
|
||||||
|
{{/if}}
|
||||||
|
</div>
|
||||||
|
<div class="sig-card">
|
||||||
|
<h3>Service Provider</h3>
|
||||||
|
<p>Ethio-Djibouti Standard Gauge Railway Share Company</p>
|
||||||
|
<div class="sig-line">Authorized representative</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
ResponseTransformInterceptor,
|
ResponseTransformInterceptor,
|
||||||
createValidationPipe,
|
createValidationPipe,
|
||||||
} from "@edr/api-common";
|
} from "@edr/api-common";
|
||||||
|
import { getAuditLoggerConfig } from "@tria-plc/auditlog";
|
||||||
|
|
||||||
import { AppModule } from "./app.module";
|
import { AppModule } from "./app.module";
|
||||||
|
|
||||||
@@ -160,6 +161,13 @@ export async function createFreightApp(): Promise<NestExpressApplication> {
|
|||||||
app.useGlobalFilters(new HttpExceptionFilter());
|
app.useGlobalFilters(new HttpExceptionFilter());
|
||||||
app.useGlobalInterceptors(new ResponseTransformInterceptor());
|
app.useGlobalInterceptors(new ResponseTransformInterceptor());
|
||||||
|
|
||||||
|
// Audit listener: consumes the RMQ events MezgebModule's client interceptor
|
||||||
|
// (app.module.ts) emits and persists them via the AuditLogController /
|
||||||
|
// AuditLogCommandController @EventPattern handlers. Same queue config the
|
||||||
|
// client side uses, reused from the package so the two never drift apart.
|
||||||
|
app.connectMicroservice(getAuditLoggerConfig());
|
||||||
|
await app.startAllMicroservices();
|
||||||
|
|
||||||
const config = new DocumentBuilder()
|
const config = new DocumentBuilder()
|
||||||
.setTitle("EDR Freight API")
|
.setTitle("EDR Freight API")
|
||||||
.setDescription("API for the EDR Freight Management application")
|
.setDescription("API for the EDR Freight Management application")
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import { MigrationInterface, QueryRunner, Table, TableForeignKey } from 'typeorm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create freight.last_mile_requests — the pre-approval confirmation stage that
|
||||||
|
* sits in front of freight.last_mile: a train departs Djibouti, the customer
|
||||||
|
* confirms which containers go via EDR last-mile, and the Truck & Machinery
|
||||||
|
* chief approves/rejects before a freight.last_mile execution record exists.
|
||||||
|
*/
|
||||||
|
export class CreateLastMileRequests3250000000000 implements MigrationInterface {
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
const exists = await queryRunner.hasTable('freight.last_mile_requests');
|
||||||
|
if (exists) return;
|
||||||
|
|
||||||
|
await queryRunner.createTable(
|
||||||
|
new Table({
|
||||||
|
name: 'freight.last_mile_requests',
|
||||||
|
columns: [
|
||||||
|
{ name: 'id', type: 'uuid', isPrimary: true, default: 'gen_random_uuid()' },
|
||||||
|
{ name: 'booking_id', type: 'uuid', isNullable: false },
|
||||||
|
{ name: 'train_schedule_id', type: 'uuid', isNullable: false },
|
||||||
|
{
|
||||||
|
name: 'status',
|
||||||
|
type: 'varchar',
|
||||||
|
length: '30',
|
||||||
|
default: `'AWAITING_CONFIRMATION'`,
|
||||||
|
isNullable: false,
|
||||||
|
},
|
||||||
|
{ name: 'requested_container_numbers', type: 'text', isArray: true, isNullable: true },
|
||||||
|
{ name: 'reminder_sent_at', type: 'timestamptz', isNullable: true },
|
||||||
|
{ name: 'submitted_by_user_id', type: 'uuid', isNullable: true },
|
||||||
|
{ name: 'submitted_at', type: 'timestamptz', isNullable: true },
|
||||||
|
{ name: 'reviewed_by_staff_id', type: 'uuid', isNullable: true },
|
||||||
|
{ name: 'reviewed_at', type: 'timestamptz', isNullable: true },
|
||||||
|
{ name: 'rejection_reason', type: 'text', isNullable: true },
|
||||||
|
{ name: 'resulting_last_mile_id', type: 'uuid', isNullable: true },
|
||||||
|
{ name: 'created_at', type: 'timestamptz', default: 'now()' },
|
||||||
|
{ name: 'updated_at', type: 'timestamptz', default: 'now()' },
|
||||||
|
{ name: 'deleted_at', type: 'timestamptz', isNullable: true },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
|
await queryRunner.createForeignKey(
|
||||||
|
'freight.last_mile_requests',
|
||||||
|
new TableForeignKey({
|
||||||
|
columnNames: ['booking_id'],
|
||||||
|
referencedTableName: 'freight.bookings',
|
||||||
|
referencedColumnNames: ['id'],
|
||||||
|
onDelete: 'CASCADE',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await queryRunner.createForeignKey(
|
||||||
|
'freight.last_mile_requests',
|
||||||
|
new TableForeignKey({
|
||||||
|
columnNames: ['train_schedule_id'],
|
||||||
|
referencedTableName: 'freight.train_schedules',
|
||||||
|
referencedColumnNames: ['id'],
|
||||||
|
onDelete: 'CASCADE',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await queryRunner.createForeignKey(
|
||||||
|
'freight.last_mile_requests',
|
||||||
|
new TableForeignKey({
|
||||||
|
columnNames: ['resulting_last_mile_id'],
|
||||||
|
referencedTableName: 'freight.last_mile',
|
||||||
|
referencedColumnNames: ['id'],
|
||||||
|
onDelete: 'SET NULL',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// One request per booking per departure — remind()/submit() are idempotent on this.
|
||||||
|
await queryRunner.query(
|
||||||
|
`CREATE UNIQUE INDEX IF NOT EXISTS "IDX_last_mile_requests_booking_schedule" ON "freight"."last_mile_requests" ("booking_id", "train_schedule_id") WHERE "deleted_at" IS NULL`,
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`CREATE INDEX IF NOT EXISTS "IDX_last_mile_requests_status" ON "freight"."last_mile_requests" ("status")`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
const exists = await queryRunner.hasTable('freight.last_mile_requests');
|
||||||
|
if (exists) {
|
||||||
|
await queryRunner.dropTable('freight.last_mile_requests');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates freight.yard_locations (GPS per yard, decimal degrees WGS84) and
|
||||||
|
* seeds the five facility yards: Sebeta, GMP/Indode (KALITY), Mojo, Adama,
|
||||||
|
* Dire Dawa. Also normalizes has_facility — only those five load/unload cargo.
|
||||||
|
*
|
||||||
|
* Coordinates are approximate — adjust rows directly if surveyed values arrive.
|
||||||
|
*/
|
||||||
|
export class YardGpsLocation3270000000000 implements MigrationInterface {
|
||||||
|
name = 'YardGpsLocation3270000000000';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS freight.yard_locations (
|
||||||
|
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
yard_id uuid NOT NULL UNIQUE REFERENCES freight.yards(id) ON DELETE CASCADE,
|
||||||
|
latitude double precision NOT NULL,
|
||||||
|
longitude double precision NOT NULL,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
deleted_at timestamptz
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
// LEGACY_DEST is the historical code for Sebeta in some environments.
|
||||||
|
await queryRunner.query(`
|
||||||
|
INSERT INTO freight.yard_locations (yard_id, latitude, longitude)
|
||||||
|
SELECT y.id, v.lat, v.lng
|
||||||
|
FROM (VALUES
|
||||||
|
('SEBETA', 8.9096, 38.6360),
|
||||||
|
('LEGACY_DEST', 8.9096, 38.6360),
|
||||||
|
('KALITY', 8.7386, 38.7913),
|
||||||
|
('MOJO', 8.5794, 39.1200),
|
||||||
|
('ADAMA', 8.5622, 39.2440),
|
||||||
|
('DIRE_DAWA', 9.6009, 41.8103)
|
||||||
|
) AS v(code, lat, lng)
|
||||||
|
JOIN freight.yards y ON y.code = v.code AND y.deleted_at IS NULL
|
||||||
|
ON CONFLICT (yard_id) DO NOTHING
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
UPDATE freight.yards
|
||||||
|
SET has_facility = (code IN ('SEBETA', 'LEGACY_DEST', 'KALITY', 'MOJO', 'ADAMA', 'DIRE_DAWA'))
|
||||||
|
WHERE deleted_at IS NULL
|
||||||
|
AND has_facility <> (code IN ('SEBETA', 'LEGACY_DEST', 'KALITY', 'MOJO', 'ADAMA', 'DIRE_DAWA'))
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(): Promise<void> {
|
||||||
|
// Additive table + data normalization; no rollback.
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Last-mile rate bands: adds min_km/max_km to freight.rates so container
|
||||||
|
* last-mile rates can be one row per (container size × distance band),
|
||||||
|
* and extends UQ_rates_pattern with the band start so sibling bands don't
|
||||||
|
* collide. Existing rows all have NULL min_km (COALESCE → -1), so the
|
||||||
|
* uniqueness semantics for every current rate are unchanged.
|
||||||
|
*/
|
||||||
|
export class LastMileRateBands3280000000000 implements MigrationInterface {
|
||||||
|
name = 'LastMileRateBands3280000000000';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.rates ADD COLUMN IF NOT EXISTS min_km numeric(10,2)
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.rates ADD COLUMN IF NOT EXISTS max_km numeric(10,2)
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
DO $$ BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM pg_constraint
|
||||||
|
WHERE conname = 'CK_rates_km_band'
|
||||||
|
AND conrelid = 'freight.rates'::regclass
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE freight.rates ADD CONSTRAINT "CK_rates_km_band"
|
||||||
|
CHECK (max_km IS NULL OR (min_km IS NOT NULL AND max_km > min_km));
|
||||||
|
END IF;
|
||||||
|
END $$
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryRunner.query(`DROP INDEX IF EXISTS freight."UQ_rates_pattern"`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_rates_pattern" ON freight.rates USING btree (
|
||||||
|
rate_type,
|
||||||
|
COALESCE(container_type_id, '00000000-0000-0000-0000-000000000000'::uuid),
|
||||||
|
COALESCE(cargo_type_id, '00000000-0000-0000-0000-000000000000'::uuid),
|
||||||
|
COALESCE(trade_direction, ''::character varying),
|
||||||
|
COALESCE(origin_yard_id, '00000000-0000-0000-0000-000000000000'::uuid),
|
||||||
|
COALESCE(destination_yard_id, '00000000-0000-0000-0000-000000000000'::uuid),
|
||||||
|
rate_unit,
|
||||||
|
COALESCE(min_km, '-1'::numeric)
|
||||||
|
) WHERE ((deleted_at IS NULL) AND ((status)::text <> 'SUPERSEDED'::text))
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`DROP INDEX IF EXISTS freight."UQ_rates_pattern"`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_rates_pattern" ON freight.rates USING btree (
|
||||||
|
rate_type,
|
||||||
|
COALESCE(container_type_id, '00000000-0000-0000-0000-000000000000'::uuid),
|
||||||
|
COALESCE(cargo_type_id, '00000000-0000-0000-0000-000000000000'::uuid),
|
||||||
|
COALESCE(trade_direction, ''::character varying),
|
||||||
|
COALESCE(origin_yard_id, '00000000-0000-0000-0000-000000000000'::uuid),
|
||||||
|
COALESCE(destination_yard_id, '00000000-0000-0000-0000-000000000000'::uuid),
|
||||||
|
rate_unit
|
||||||
|
) WHERE ((deleted_at IS NULL) AND ((status)::text <> 'SUPERSEDED'::text))
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.rates DROP CONSTRAINT IF EXISTS "CK_rates_km_band"
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`ALTER TABLE freight.rates DROP COLUMN IF EXISTS max_km`);
|
||||||
|
await queryRunner.query(`ALTER TABLE freight.rates DROP COLUMN IF EXISTS min_km`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Last-mile contract fields on freight.last_mile_requests: the customer-chosen
|
||||||
|
* delivery date, the chief-approved advance amount (held until the invoice is
|
||||||
|
* generated at signing time), the rate snapshot rendered into the contract,
|
||||||
|
* and the customer signature bookkeeping. The signed PDF and signature image
|
||||||
|
* live in freight.files (resource 'last_mile_requests').
|
||||||
|
*/
|
||||||
|
export class LastMileRequestContract3290000000000 implements MigrationInterface {
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.last_mile_requests
|
||||||
|
ADD COLUMN IF NOT EXISTS requested_delivery_date date,
|
||||||
|
ADD COLUMN IF NOT EXISTS approved_advance_amount numeric(14,2),
|
||||||
|
ADD COLUMN IF NOT EXISTS contract_summary jsonb,
|
||||||
|
ADD COLUMN IF NOT EXISTS contract_generated_at timestamptz,
|
||||||
|
ADD COLUMN IF NOT EXISTS customer_signed_at timestamptz,
|
||||||
|
ADD COLUMN IF NOT EXISTS signer_display_name varchar(160),
|
||||||
|
ADD COLUMN IF NOT EXISTS consent_text text
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.last_mile_requests
|
||||||
|
DROP COLUMN IF EXISTS requested_delivery_date,
|
||||||
|
DROP COLUMN IF EXISTS approved_advance_amount,
|
||||||
|
DROP COLUMN IF EXISTS contract_summary,
|
||||||
|
DROP COLUMN IF EXISTS contract_generated_at,
|
||||||
|
DROP COLUMN IF EXISTS customer_signed_at,
|
||||||
|
DROP COLUMN IF EXISTS signer_display_name,
|
||||||
|
DROP COLUMN IF EXISTS consent_text
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
32
apps/edr-freight-api/src/modules/audit/audit.controller.ts
Normal file
32
apps/edr-freight-api/src/modules/audit/audit.controller.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { Controller, Get, Query } from "@nestjs/common";
|
||||||
|
import { ApiOperation, ApiQuery, ApiTags } from "@nestjs/swagger";
|
||||||
|
|
||||||
|
import { BookingStaff } from "../../common/booking-guards";
|
||||||
|
import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
|
||||||
|
import { AuditService } from "./audit.service";
|
||||||
|
|
||||||
|
@ApiTags("audit")
|
||||||
|
@Controller("audit")
|
||||||
|
@BookingStaff(FREIGHT_PERMS.audit.view)
|
||||||
|
export class AuditController {
|
||||||
|
constructor(private readonly auditService: AuditService) {}
|
||||||
|
|
||||||
|
@Get("logs")
|
||||||
|
@ApiOperation({ summary: "List freight-api audit log commands" })
|
||||||
|
@ApiQuery({ name: "skip", type: Number, required: false })
|
||||||
|
@ApiQuery({ name: "take", type: Number, required: false })
|
||||||
|
list(@Query("skip") skip?: string, @Query("take") take?: string) {
|
||||||
|
// Same fallback chain @tria-plc/auditlog's client interceptor uses to
|
||||||
|
// stamp AuditLog.application (mezgeb/client/client-audit.interceptor.js)
|
||||||
|
// — reading it here instead of a hardcoded literal means this can't
|
||||||
|
// silently drift out of sync with whatever APPLICATION_NAME/APP_NAME
|
||||||
|
// actually is at runtime.
|
||||||
|
const application =
|
||||||
|
process.env.APPLICATION_NAME ?? process.env.APP_NAME ?? "DEFAULT";
|
||||||
|
return this.auditService.list(
|
||||||
|
application,
|
||||||
|
skip !== undefined ? parseInt(skip, 10) : undefined,
|
||||||
|
take !== undefined ? parseInt(take, 10) : undefined,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
13
apps/edr-freight-api/src/modules/audit/audit.module.ts
Normal file
13
apps/edr-freight-api/src/modules/audit/audit.module.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { Module } from "@nestjs/common";
|
||||||
|
import { TypeOrmModule } from "@nestjs/typeorm";
|
||||||
|
import { AuditLogCommand } from "@tria-plc/auditlog";
|
||||||
|
|
||||||
|
import { AuditController } from "./audit.controller";
|
||||||
|
import { AuditService } from "./audit.service";
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [TypeOrmModule.forFeature([AuditLogCommand])],
|
||||||
|
controllers: [AuditController],
|
||||||
|
providers: [AuditService],
|
||||||
|
})
|
||||||
|
export class AuditModule {}
|
||||||
70
apps/edr-freight-api/src/modules/audit/audit.service.ts
Normal file
70
apps/edr-freight-api/src/modules/audit/audit.service.ts
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import { Injectable } from "@nestjs/common";
|
||||||
|
import { InjectRepository } from "@nestjs/typeorm";
|
||||||
|
import { Repository } from "typeorm";
|
||||||
|
import { AuditLogCommand } from "@tria-plc/auditlog";
|
||||||
|
|
||||||
|
import { CLIENT_APP_HEADER } from "../auth/login-audience.middleware";
|
||||||
|
|
||||||
|
export interface AuditLogListResult {
|
||||||
|
count: number;
|
||||||
|
items: AuditLogCommand[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Own read path onto @tria-plc/auditlog's tables, gated by AuditController's
|
||||||
|
* @BookingStaff — the package's own AuditLogCommandController (mounted at
|
||||||
|
* /api/audit-log-commands) ships with no guards at all, so it can't be used
|
||||||
|
* directly for a permission-gated UI. Query mirrors the package's
|
||||||
|
* AuditLogCommandService.buildAuditLogQuery/getAllAuditLogs exactly.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class AuditService {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(AuditLogCommand)
|
||||||
|
private readonly auditLogCommandRepository: Repository<AuditLogCommand>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async list(
|
||||||
|
application: string,
|
||||||
|
skip = 0,
|
||||||
|
take = 10,
|
||||||
|
): Promise<AuditLogListResult> {
|
||||||
|
const [items, count] = await this.auditLogCommandRepository
|
||||||
|
.createQueryBuilder("audit_log_commands")
|
||||||
|
.leftJoinAndSelect("audit_log_commands.auditLog", "auditLog")
|
||||||
|
.andWhere(
|
||||||
|
"(audit_log_commands.auditLogId IS NULL OR auditLog.application = :application)",
|
||||||
|
{ application },
|
||||||
|
)
|
||||||
|
.andWhere(
|
||||||
|
"(audit_log_commands.auditLogId IS NULL OR auditLog.status = :status)",
|
||||||
|
{ status: "Commit" },
|
||||||
|
)
|
||||||
|
// Backoffice-only view: portal (customer-facing) writes carry the same
|
||||||
|
// request-header set by every axios call from that app — see
|
||||||
|
// login-audience.middleware.ts. Rows with no linked auditLog (child/
|
||||||
|
// event commands with no request context) stay visible; they aren't
|
||||||
|
// attributable to any frontend, so they're not portal noise either.
|
||||||
|
.andWhere(
|
||||||
|
"(audit_log_commands.auditLogId IS NULL OR auditLog.requestHeader ->> :clientAppHeader = :clientApp)",
|
||||||
|
{ clientAppHeader: CLIENT_APP_HEADER, clientApp: "backoffice" },
|
||||||
|
)
|
||||||
|
.select([
|
||||||
|
"audit_log_commands.id",
|
||||||
|
"audit_log_commands.createdAt",
|
||||||
|
"audit_log_commands.deletedAt",
|
||||||
|
"audit_log_commands.entityName",
|
||||||
|
"audit_log_commands.queryMethod",
|
||||||
|
"audit_log_commands.changes",
|
||||||
|
"audit_log_commands.payload",
|
||||||
|
"auditLog.id",
|
||||||
|
"auditLog.user",
|
||||||
|
])
|
||||||
|
.addOrderBy("audit_log_commands.createdAt", "DESC")
|
||||||
|
.skip(skip)
|
||||||
|
.take(take)
|
||||||
|
.getManyAndCount();
|
||||||
|
|
||||||
|
return { count, items };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -668,3 +668,66 @@ describe("BillingService — CAC Bank (OTP debit)", () => {
|
|||||||
expect(confirmOtp).toHaveBeenCalledWith("intent-1", "123456");
|
expect(confirmOtp).toHaveBeenCalledWith("intent-1", "123456");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("BillingService — CBE bill amounts round UP to whole birr", () => {
|
||||||
|
// CBE bills whole birr. Ceil, never Math.round: a .40 balance rounded down
|
||||||
|
// settles 0.40 short while markInvoiceAsPaid still writes paidAmount =
|
||||||
|
// totalAmount — money missing from the bank with the books saying paid.
|
||||||
|
// payInvoice and billQuery must agree, or /cbe/payment sees a mismatch.
|
||||||
|
const invoice = {
|
||||||
|
id: "inv-1",
|
||||||
|
status: Freight.InvoiceStatus.Pending,
|
||||||
|
source: Freight.InvoiceSource.Booking,
|
||||||
|
sourceId: "booking-1",
|
||||||
|
type: "PREPAID",
|
||||||
|
invoiceNumber: "INV-20260101-00001",
|
||||||
|
currency: "ETB",
|
||||||
|
// .40 — the case Math.round gets wrong (rounds down, underpays).
|
||||||
|
balanceAmount: 12345.4,
|
||||||
|
totalAmount: 12345.4,
|
||||||
|
company: { name: "Acme PLC" },
|
||||||
|
paymentId: null,
|
||||||
|
dueAt: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const build = (payment: Record<string, unknown> = {}) => {
|
||||||
|
const repo = {
|
||||||
|
findOne: jest.fn().mockResolvedValue(invoice),
|
||||||
|
update: jest.fn().mockResolvedValue(undefined),
|
||||||
|
};
|
||||||
|
const service = new BillingService(
|
||||||
|
{ getRepository: () => repo } as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
makeEvents() as never,
|
||||||
|
payment as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
);
|
||||||
|
return { service, repo };
|
||||||
|
};
|
||||||
|
|
||||||
|
it("opens the intent for the ceiled balance, never below it", async () => {
|
||||||
|
const initiate = jest.fn().mockResolvedValue({
|
||||||
|
intentId: "intent-1",
|
||||||
|
immediateSuccess: false,
|
||||||
|
response: { intentId: "intent-1", status: "REQUIRES_ACTION" },
|
||||||
|
});
|
||||||
|
const { service } = build({ initiate });
|
||||||
|
|
||||||
|
await service.payInvoice("inv-1", { method: "CBE_BILL" });
|
||||||
|
|
||||||
|
expect(initiate).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ amountMinor: 12346 }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("quotes the same ceiled amount on bill-query as payInvoice opened", async () => {
|
||||||
|
const { service } = build();
|
||||||
|
|
||||||
|
await expect(service.billQuery("booking-1")).resolves.toMatchObject({
|
||||||
|
stillPayable: true,
|
||||||
|
currentAmountMinor: 12346,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
import { EventEmitter2 } from "@nestjs/event-emitter";
|
import { EventEmitter2 } from "@nestjs/event-emitter";
|
||||||
import { DataSource, EntityManager, In } from "typeorm";
|
import { DataSource, EntityManager, In } from "typeorm";
|
||||||
|
|
||||||
|
import { Booking } from "../bookings/entities/booking.entity";
|
||||||
import { CompaniesService } from "../companies/companies.service";
|
import { CompaniesService } from "../companies/companies.service";
|
||||||
import { applyBookingRefDirectionScope } from "../user-trade-access/trade-scope.util";
|
import { applyBookingRefDirectionScope } from "../user-trade-access/trade-scope.util";
|
||||||
import { PaymentService } from "../payment/payment.service";
|
import { PaymentService } from "../payment/payment.service";
|
||||||
@@ -1190,7 +1191,11 @@ export class BillingService {
|
|||||||
// service branches on a domain-specific reference type.
|
// service branches on a domain-specific reference type.
|
||||||
referenceType: PaymentReferenceType.SHIPMENT,
|
referenceType: PaymentReferenceType.SHIPMENT,
|
||||||
orderRef: invoice.invoiceNumber.replace(/-/g, "_"),
|
orderRef: invoice.invoiceNumber.replace(/-/g, "_"),
|
||||||
amountMinor: Math.round(Number(invoice.balanceAmount)),
|
// Whole birr, always UP. CBE bills this amount verbatim, so it must never
|
||||||
|
// land below the outstanding balance — Math.round would let a .40 balance
|
||||||
|
// settle 0.40 short. Ceil overcharges by <1 birr instead, and the same
|
||||||
|
// ceil in billQuery keeps the quoted and debited amounts identical.
|
||||||
|
amountMinor: Math.ceil(Number(invoice.balanceAmount)),
|
||||||
currency: invoice.currency,
|
currency: invoice.currency,
|
||||||
reason: `Payment for invoice ${invoice.invoiceNumber}`,
|
reason: `Payment for invoice ${invoice.invoiceNumber}`,
|
||||||
method: opts.method ?? "TELEBIRR",
|
method: opts.method ?? "TELEBIRR",
|
||||||
@@ -1209,6 +1214,17 @@ export class BillingService {
|
|||||||
.getRepository(Invoice)
|
.getRepository(Invoice)
|
||||||
.update({ id: invoice.id }, { paymentId: result.intentId });
|
.update({ id: invoice.id }, { paymentId: result.intentId });
|
||||||
|
|
||||||
|
// CBE_BILL: the bill reference IS the booking's PNR — the number the customer pays against
|
||||||
|
// at any CBE channel. Persist it on the booking so it survives the initiate response and
|
||||||
|
// shows on the booking/contract everywhere. The payment service reissues the same reference
|
||||||
|
// while the bill stays open, so re-initiating overwrites with an identical value.
|
||||||
|
const billReference = result.response.clientAction?.billReference;
|
||||||
|
if (billReference && invoice.source === Freight.InvoiceSource.Booking) {
|
||||||
|
await this.dataSource
|
||||||
|
.getRepository(Booking)
|
||||||
|
.update({ id: invoice.sourceId }, { pnrCode: billReference });
|
||||||
|
}
|
||||||
|
|
||||||
// Settlement is driven by the payment API (webhook/outbox → payment.succeeded);
|
// Settlement is driven by the payment API (webhook/outbox → payment.succeeded);
|
||||||
// billing must not simulate it. Kept for local demos only.
|
// billing must not simulate it. Kept for local demos only.
|
||||||
// An OTP intent (CAC Bank) is NOT paid yet — the payer still has to enter the
|
// An OTP intent (CAC Bank) is NOT paid yet — the payer still has to enter the
|
||||||
@@ -1324,7 +1340,9 @@ export class BillingService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (open) {
|
if (open) {
|
||||||
const balance = Math.round(Number(open.balanceAmount ?? open.totalAmount));
|
// Ceil, matching payInvoice — the amount CBE quotes at the counter has to
|
||||||
|
// be the amount the intent was opened for, or /cbe/payment sees a mismatch.
|
||||||
|
const balance = Math.ceil(Number(open.balanceAmount ?? open.totalAmount));
|
||||||
const expired = open.dueAt && open.dueAt.getTime() < Date.now();
|
const expired = open.dueAt && open.dueAt.getTime() < Date.now();
|
||||||
return {
|
return {
|
||||||
stillPayable: balance > 0 && !expired,
|
stillPayable: balance > 0 && !expired,
|
||||||
@@ -1359,7 +1377,7 @@ export class BillingService {
|
|||||||
return {
|
return {
|
||||||
stillPayable: false,
|
stillPayable: false,
|
||||||
payerName: latest.company?.name ?? null,
|
payerName: latest.company?.name ?? null,
|
||||||
currentAmountMinor: Math.round(Number(latest.totalAmount)),
|
currentAmountMinor: Math.ceil(Number(latest.totalAmount)),
|
||||||
currency: latest.currency,
|
currency: latest.currency,
|
||||||
paymentReason: `Freight invoice ${latest.invoiceNumber}`,
|
paymentReason: `Freight invoice ${latest.invoiceNumber}`,
|
||||||
reason: closedInvoiceReason(latest.status),
|
reason: closedInvoiceReason(latest.status),
|
||||||
|
|||||||
@@ -727,8 +727,16 @@ export class BookingPricingService {
|
|||||||
|
|
||||||
for (const leg of legs) {
|
for (const leg of legs) {
|
||||||
if (!leg.active) continue;
|
if (!leg.active) continue;
|
||||||
|
// New-style last-mile rates (PER_TON_KM bulk / distance-banded PER_KM)
|
||||||
|
// price the operational leg via last-mile-charge.util, not the booking
|
||||||
|
// quote — this legacy lookup must never pick one up.
|
||||||
const rate = liveRates.find(
|
const rate = liveRates.find(
|
||||||
(r) => r.rateType === leg.rateType && r.currency === 'USD' && r.status === 'LIVE',
|
(r) =>
|
||||||
|
r.rateType === leg.rateType &&
|
||||||
|
r.currency === 'USD' &&
|
||||||
|
r.status === 'LIVE' &&
|
||||||
|
r.rateUnit !== 'PER_TON_KM' &&
|
||||||
|
r.minKm == null,
|
||||||
);
|
);
|
||||||
if (!rate) continue;
|
if (!rate) continue;
|
||||||
|
|
||||||
|
|||||||
@@ -280,15 +280,104 @@ describe("Fayda identity verification binds a person to the company", () => {
|
|||||||
expect(ctx.attributes.poaFaydaSub).toBe("new-sub");
|
expect(ctx.attributes.poaFaydaSub).toBe("new-sub");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("refuses to rename a verified person by hand", async () => {
|
it("stages nothing for a verified field an approved company resubmits", async () => {
|
||||||
const { service } = makeService({
|
// Approving it could not move the live row — the verified value is written
|
||||||
|
// back over it — so it must never reach a reviewer as a pending change.
|
||||||
|
const { service, deps } = makeService({
|
||||||
|
status: CompanyStatus.Active,
|
||||||
|
attributes: { ...OWNER_VERIFIED, ownerEmail: "abebe@example.com" },
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.updateProfile("user-1", {
|
||||||
|
companyEmail: "someone-else@example.com",
|
||||||
|
} as never),
|
||||||
|
).resolves.toBeDefined();
|
||||||
|
expect(deps.changeRequestRepo.create).not.toHaveBeenCalled();
|
||||||
|
expect(deps.changeRequestRepo.update).not.toHaveBeenCalled();
|
||||||
|
expect(deps.companiesRepo.update).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
// The verified value wins, and it wins by overwriting rather than by
|
||||||
|
// rejecting: nobody types these fields, so a submission that disagrees is a
|
||||||
|
// stale form echoing itself back, not an edit. Failing it would block a save
|
||||||
|
// the customer never made — and leave them no way through, since re-verifying
|
||||||
|
// returns the same value they are being 400'd for.
|
||||||
|
it("overwrites a hand-renamed verified person with the verified name", async () => {
|
||||||
|
const { service, ctx } = makeService({
|
||||||
attributes: { ...OWNER_VERIFIED, ...POA_VERIFIED },
|
attributes: { ...OWNER_VERIFIED, ...POA_VERIFIED },
|
||||||
files: [paper()],
|
files: [paper()],
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
service.updateProfile("user-1", { poaName: "Someone Else" } as never),
|
service.updateProfile("user-1", { poaName: "Someone Else" } as never),
|
||||||
).rejects.toBeInstanceOf(BadRequestException);
|
).resolves.toBeDefined();
|
||||||
|
expect(ctx.attributes.poaName).toBe(POA_VERIFIED.poaName);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fayda's email and phone claims are optional — a verification can prove the
|
||||||
|
// person and return neither. Holding the company mirrors to "the owner is
|
||||||
|
// verified" rather than to "the verification supplied this value" would
|
||||||
|
// clobber the fallbacks the portal is built to send (account email, eTrade's
|
||||||
|
// registered phone) with nothing at all. OWNER_VERIFIED is exactly that
|
||||||
|
// shape: a sub, no contact details.
|
||||||
|
it("keeps company contact details a Fayda verification never supplied", async () => {
|
||||||
|
const { service, deps } = makeService({
|
||||||
|
attributes: { ...OWNER_VERIFIED },
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.updateProfile("user-1", {
|
||||||
|
companyEmail: "account@example.com",
|
||||||
|
companyPhone: "+251911777777",
|
||||||
|
} as never),
|
||||||
|
).resolves.toBeDefined();
|
||||||
|
const [, patch] = deps.companiesRepo.update.mock.calls.at(-1)!;
|
||||||
|
expect(patch.email).toBe("account@example.com");
|
||||||
|
expect(patch.phone).toBe("+251911777777");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("overwrites company contact details the verification did supply", async () => {
|
||||||
|
const { service, deps } = makeService({
|
||||||
|
attributes: {
|
||||||
|
...OWNER_VERIFIED,
|
||||||
|
ownerEmail: "abebe@example.com",
|
||||||
|
ownerPhone: "+251911000000",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.updateProfile("user-1", {
|
||||||
|
companyEmail: "someone-else@example.com",
|
||||||
|
companyPhone: "+251911999999",
|
||||||
|
} as never),
|
||||||
|
).resolves.toBeDefined();
|
||||||
|
const [, patch] = deps.companiesRepo.update.mock.calls.at(-1)!;
|
||||||
|
expect(patch.email).toBe("abebe@example.com");
|
||||||
|
expect(patch.phone).toBe("+251911000000");
|
||||||
|
});
|
||||||
|
|
||||||
|
// "Same as owner" copies `ownerEmail ?? null` onto the GM while setting
|
||||||
|
// `gmFaydaSub`. Locking that null made generalManagerEmail required by
|
||||||
|
// onboarding, hidden by the portal's link card and unwritable at once.
|
||||||
|
it("lets the GM's details be typed when the copied owner identity carried none", async () => {
|
||||||
|
const { service } = makeService({
|
||||||
|
attributes: {
|
||||||
|
...OWNER_VERIFIED,
|
||||||
|
gmSameAsOwner: true,
|
||||||
|
gmFaydaSub: "owner-sub",
|
||||||
|
generalManagerName: "Abebe Bikila",
|
||||||
|
generalManagerEmail: null,
|
||||||
|
generalManagerPhone: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.updateProfile("user-1", {
|
||||||
|
generalManagerEmail: "gm@example.com",
|
||||||
|
generalManagerPhone: "+251911888888",
|
||||||
|
} as never),
|
||||||
|
).resolves.toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("never locks or gates the general manager — it is not the verified subject", async () => {
|
it("never locks or gates the general manager — it is not the verified subject", async () => {
|
||||||
|
|||||||
@@ -739,6 +739,39 @@ export class CompaniesService {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `UpdateProfileDto` keys this company's completed verifications own — the
|
||||||
|
* ones `mapProfileDtoToCompanyUpdates` overwrites with the verified value
|
||||||
|
* whatever a request submits for them.
|
||||||
|
*
|
||||||
|
* A key only lands here once there is a verified value to hold it to: Fayda's
|
||||||
|
* email and phone claims are optional, and a verification that returned
|
||||||
|
* neither owns nothing to overwrite with.
|
||||||
|
*
|
||||||
|
* The map is the enforcement; this is the list used to keep those keys out of
|
||||||
|
* a change request in the first place. If the two ever drift the map still
|
||||||
|
* wins — the cost is a staged field that approving turns out not to move.
|
||||||
|
*/
|
||||||
|
private faydaOwnedKeys(company: Company): string[] {
|
||||||
|
const attrs = company.attributes ?? {};
|
||||||
|
const held = (key: string) => {
|
||||||
|
const v = attrs[key];
|
||||||
|
return v !== null && v !== undefined && v !== "";
|
||||||
|
};
|
||||||
|
|
||||||
|
const keys: string[] = [];
|
||||||
|
if (attrs.ownerFaydaSub) {
|
||||||
|
// The Company-column mirrors of the owner's verified contact details.
|
||||||
|
if (held("ownerEmail")) keys.push("companyEmail");
|
||||||
|
if (held("ownerPhone")) keys.push("companyPhone");
|
||||||
|
}
|
||||||
|
for (const subject of IDENTITY_SUBJECTS) {
|
||||||
|
if (!attrs[`${IDENTITY_PREFIX[subject]}FaydaSub`]) continue;
|
||||||
|
keys.push(...IDENTITY_OWNED_FIELDS[subject].filter(held));
|
||||||
|
}
|
||||||
|
return keys;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Translate an UpdateProfileDto (or a staged change-request snapshot) into a
|
* Translate an UpdateProfileDto (or a staged change-request snapshot) into a
|
||||||
* `Company` patch: scalar columns plus a merged `attributes` blob (contact/GM/
|
* `Company` patch: scalar columns plus a merged `attributes` blob (contact/GM/
|
||||||
@@ -829,49 +862,46 @@ export class CompaniesService {
|
|||||||
// lets the customer type them once verified) — lock them the same way
|
// lets the customer type them once verified) — lock them the same way
|
||||||
// ownerEmail/ownerPhone themselves are locked below, once there is a
|
// ownerEmail/ownerPhone themselves are locked below, once there is a
|
||||||
// verified owner to lock them to.
|
// verified owner to lock them to.
|
||||||
|
//
|
||||||
|
// Keyed on the verified VALUE, not on `ownerFaydaSub`: Fayda's email and
|
||||||
|
// phone claims are optional, so a verification can prove the person while
|
||||||
|
// supplying neither (see completeIdentityVerification's conditional
|
||||||
|
// spreads). The portal falls back to the account email / eTrade's
|
||||||
|
// registered phone in exactly that case and submits it on every save of
|
||||||
|
// the company step — locking against an absent value would 400 that
|
||||||
|
// forever, and re-verifying could never clear it because Fayda still has
|
||||||
|
// nothing to return.
|
||||||
if (attrUpdates.ownerFaydaSub) {
|
if (attrUpdates.ownerFaydaSub) {
|
||||||
if (
|
if (attrUpdates.ownerEmail && dto.companyEmail !== undefined)
|
||||||
dto.companyEmail !== undefined &&
|
companyUpdates.email = attrUpdates.ownerEmail;
|
||||||
dto.companyEmail !== attrUpdates.ownerEmail
|
if (attrUpdates.ownerPhone && dto.companyPhone !== undefined)
|
||||||
) {
|
companyUpdates.phone = normalizeE164(String(attrUpdates.ownerPhone));
|
||||||
throw new BadRequestException(
|
|
||||||
"companyEmail is set by the owner's Fayda verification and cannot be edited. Re-verify to change it.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
dto.companyPhone !== undefined &&
|
|
||||||
normalizeE164(dto.companyPhone) !==
|
|
||||||
normalizeE164(String(attrUpdates.ownerPhone ?? ""))
|
|
||||||
) {
|
|
||||||
throw new BadRequestException(
|
|
||||||
"companyPhone is set by the owner's Fayda verification and cannot be edited. Re-verify to change it.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Renaming a Fayda-verified person by hand would launder the guarantee
|
// Renaming a Fayda-verified person by hand would launder the guarantee
|
||||||
// away, so the fields the verification owns are refused once it exists.
|
// away, so the verification keeps these fields: a submission that disagrees
|
||||||
|
// is overwritten with the verified value rather than rejected — the same
|
||||||
|
// doctrine `applyEtradeSourcedFields` uses for eTrade's fields, and for the
|
||||||
|
// same reason. The customer never types these (the portal derives them, and
|
||||||
|
// a stale form or a re-render can echo back something else entirely), so a
|
||||||
|
// 400 punishes a save they never made while an overwrite lands the truth.
|
||||||
for (const subject of IDENTITY_SUBJECTS) {
|
for (const subject of IDENTITY_SUBJECTS) {
|
||||||
if (!attrUpdates[`${IDENTITY_PREFIX[subject]}FaydaSub`]) continue;
|
if (!attrUpdates[`${IDENTITY_PREFIX[subject]}FaydaSub`]) continue;
|
||||||
for (const field of IDENTITY_OWNED_FIELDS[subject]) {
|
for (const field of IDENTITY_OWNED_FIELDS[subject]) {
|
||||||
const incoming = (dto as Record<string, unknown>)[field];
|
if ((dto as Record<string, unknown>)[field] === undefined) continue;
|
||||||
if (incoming === undefined) continue;
|
// The verification itself is what writes them; it must not be undone by
|
||||||
// The verification itself is allowed to write them; anything else is
|
// the value this same call just copied into the patch.
|
||||||
// compared against what is already stored, not against the value this
|
|
||||||
// same call just copied into the patch. Phones are compared normalized:
|
|
||||||
// a form that re-renders +251911000000 as 0911000000 is echoing the
|
|
||||||
// stored value back, not trying to change it.
|
|
||||||
if (dto.faydaIdentity && field in dto.faydaIdentity) continue;
|
if (dto.faydaIdentity && field in dto.faydaIdentity) continue;
|
||||||
const stored = company.attributes?.[field];
|
const stored = company.attributes?.[field];
|
||||||
const same = field.endsWith("Phone")
|
// A verification that supplied nothing for this field left no guarantee
|
||||||
? normalizeE164(String(incoming)) ===
|
// to protect, so it stays typeable. Matters most for the GM —
|
||||||
normalizeE164(String(stored ?? ""))
|
// `setGmSameAsOwner` copies `ownerEmail ?? null` onto
|
||||||
: incoming === stored;
|
// `generalManagerEmail` while setting `gmFaydaSub`, and
|
||||||
if (!same) {
|
// REQUIRED_COMPANY_INFO still demands that email, so holding a null
|
||||||
throw new BadRequestException(
|
// here makes it required, hidden by the portal's "same as owner" card,
|
||||||
`${field} is set by the Fayda verification of this company's ${IDENTITY_LABEL[subject]} and cannot be edited. Re-verify to change it.`,
|
// and unwritable all at once.
|
||||||
);
|
if (stored === null || stored === undefined || stored === "") continue;
|
||||||
}
|
attrUpdates[field] = stored;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -977,6 +1007,11 @@ export class CompaniesService {
|
|||||||
// for review with the live row left intact.
|
// for review with the live row left intact.
|
||||||
await this.assertTinAvailable(company, dto.tin);
|
await this.assertTinAvailable(company, dto.tin);
|
||||||
const fields = this.pickDefined(dto);
|
const fields = this.pickDefined(dto);
|
||||||
|
// Drop what the verifications own before anything is staged. Approving one
|
||||||
|
// of these could not change the live row — mapProfileDtoToCompanyUpdates
|
||||||
|
// writes the verified value back over it — so showing it to a reviewer
|
||||||
|
// asks them to rule on a change that does not exist.
|
||||||
|
for (const key of this.faydaOwnedKeys(company)) delete fields[key];
|
||||||
const selfService: Record<string, any> = {};
|
const selfService: Record<string, any> = {};
|
||||||
const staged: Record<string, any> = {};
|
const staged: Record<string, any> = {};
|
||||||
for (const [key, value] of Object.entries(fields)) {
|
for (const [key, value] of Object.entries(fields)) {
|
||||||
@@ -2022,6 +2057,11 @@ export class CompaniesService {
|
|||||||
// replace it before the application counts as complete.
|
// replace it before the application counts as complete.
|
||||||
const flaggedDelegation = delegationDue && delegation.flagged;
|
const flaggedDelegation = delegationDue && delegation.flagged;
|
||||||
|
|
||||||
|
// Mirrors `poaProven` in buildCompanyIdentityState — see the note there.
|
||||||
|
const poaProven = identity.faydaRequired
|
||||||
|
? identity.poa.verified
|
||||||
|
: identity.poa.verified || Boolean(identity.poa.name?.trim());
|
||||||
|
|
||||||
const outstanding = [
|
const outstanding = [
|
||||||
...missingInfo.map((f) => `Add your ${f.label.toLowerCase()}`),
|
...missingInfo.map((f) => `Add your ${f.label.toLowerCase()}`),
|
||||||
...missingDocs.map((d) => `Upload your ${d.fileLabel}`),
|
...missingDocs.map((d) => `Upload your ${d.fileLabel}`),
|
||||||
@@ -2039,8 +2079,19 @@ export class CompaniesService {
|
|||||||
...(identity.faydaRequired && !identity.owner.verified
|
...(identity.faydaRequired && !identity.owner.verified
|
||||||
? ["Verify the company owner's identity with Fayda"]
|
? ["Verify the company owner's identity with Fayda"]
|
||||||
: []),
|
: []),
|
||||||
...((poaRequired || poaProvided) && !identity.poa.verified
|
// Nationality-aware, exactly like `poaProven` in
|
||||||
? ["Verify your Power of Attorney's identity with Fayda"]
|
// buildCompanyIdentityState and the check in `assertIdentityVerified`:
|
||||||
|
// Fayda is an Ethiopian national ID, so a foreign company's typed
|
||||||
|
// representative has to count. Demanding a verification here regardless
|
||||||
|
// made this list disagree with the rule actually enforced, and left a
|
||||||
|
// foreign freight forwarder unable to submit — asked for a Fayda
|
||||||
|
// verification its representative may have no way to obtain.
|
||||||
|
...((poaRequired || poaProvided) && !poaProven
|
||||||
|
? [
|
||||||
|
identity.faydaRequired
|
||||||
|
? "Verify your Power of Attorney's identity with Fayda"
|
||||||
|
: "Name your Power of Attorney, or verify them with Fayda",
|
||||||
|
]
|
||||||
: []),
|
: []),
|
||||||
...(identity.passportRequired && !identity.owner.passportNumber
|
...(identity.passportRequired && !identity.owner.passportNumber
|
||||||
? ["Add the company owner's passport number"]
|
? ["Add the company owner's passport number"]
|
||||||
@@ -2054,7 +2105,10 @@ export class CompaniesService {
|
|||||||
const poaItemCount = delegationDue ? 1 : 0;
|
const poaItemCount = delegationDue ? 1 : 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 — that one is Fayda whatever the nationality.
|
// there is one — Fayda for an Ethiopian company, a named representative
|
||||||
|
// for a foreign one, same rule as `poaProven` above. Counting a foreign
|
||||||
|
// company's typed PoA as unproven here left the progress bar permanently
|
||||||
|
// short of 100% on an item it had already satisfied.
|
||||||
const ownerCredentialDue =
|
const ownerCredentialDue =
|
||||||
identity.faydaRequired || identity.passportRequired;
|
identity.faydaRequired || identity.passportRequired;
|
||||||
const ownerCredentialProven = identity.faydaRequired
|
const ownerCredentialProven = identity.faydaRequired
|
||||||
@@ -2064,7 +2118,7 @@ export class CompaniesService {
|
|||||||
(ownerCredentialDue ? 1 : 0) + (delegationDue ? 1 : 0);
|
(ownerCredentialDue ? 1 : 0) + (delegationDue ? 1 : 0);
|
||||||
const missingIdentityCount =
|
const missingIdentityCount =
|
||||||
(ownerCredentialDue && !ownerCredentialProven ? 1 : 0) +
|
(ownerCredentialDue && !ownerCredentialProven ? 1 : 0) +
|
||||||
(delegationDue && !identity.poa.verified ? 1 : 0);
|
(delegationDue && !poaProven ? 1 : 0);
|
||||||
const total =
|
const total =
|
||||||
requiredInfo.length +
|
requiredInfo.length +
|
||||||
requiredDocCount +
|
requiredDocCount +
|
||||||
@@ -2732,7 +2786,13 @@ export class CompaniesService {
|
|||||||
// The verified payload owns the person's details from here on.
|
// The verified payload owns the person's details from here on.
|
||||||
...(result.fullName ? { [`${prefix}Name`]: result.fullName } : {}),
|
...(result.fullName ? { [`${prefix}Name`]: result.fullName } : {}),
|
||||||
...(result.email ? { [`${prefix}Email`]: result.email } : {}),
|
...(result.email ? { [`${prefix}Email`]: result.email } : {}),
|
||||||
...(result.phoneNumber ? { [`${prefix}Phone`]: result.phoneNumber } : {}),
|
// Fayda returns whatever the national registry holds, which is routinely a
|
||||||
|
// local number ("0911223344"). Every typed phone in this service is stored
|
||||||
|
// E.164, and `@IsValidPhone()` rejects anything else — so a raw claim here
|
||||||
|
// becomes a value the portal reads back and cannot resubmit.
|
||||||
|
...(result.phoneNumber
|
||||||
|
? { [`${prefix}Phone`]: normalizeE164(result.phoneNumber) }
|
||||||
|
: {}),
|
||||||
...(result.address ? { [`${prefix}Address`]: result.address } : {}),
|
...(result.address ? { [`${prefix}Address`]: result.address } : {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,12 @@
|
|||||||
import { IsString, IsOptional, IsEmail, MaxLength, IsEnum, IsIn } from 'class-validator';
|
import {
|
||||||
|
IsString,
|
||||||
|
IsOptional,
|
||||||
|
IsEmail,
|
||||||
|
MaxLength,
|
||||||
|
IsEnum,
|
||||||
|
IsIn,
|
||||||
|
Matches,
|
||||||
|
} from 'class-validator';
|
||||||
import { ETHIOPIAN_REGIONS, type EthiopianRegion } from '@edr/types';
|
import { ETHIOPIAN_REGIONS, type EthiopianRegion } from '@edr/types';
|
||||||
import { CompanyNationality } from '../entities/company.entity';
|
import { CompanyNationality } from '../entities/company.entity';
|
||||||
import { IsValidPhone } from '../../../common/validators/is-phone-number.validator';
|
import { IsValidPhone } from '../../../common/validators/is-phone-number.validator';
|
||||||
@@ -39,9 +47,13 @@ export class UpdateProfileDto {
|
|||||||
@IsTin({ message: 'TIN must be exactly 10 digits' })
|
@IsTin({ message: 'TIN must be exactly 10 digits' })
|
||||||
tin?: string;
|
tin?: string;
|
||||||
|
|
||||||
|
// Ethiopian VAT registration numbers are 10 digits, the same shape as the
|
||||||
|
// TIN. Both portal forms enforce that; without it here the API happily stored
|
||||||
|
// whatever a stale client sent, and the two layers disagreed about what the
|
||||||
|
// column may hold.
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
@MaxLength(50)
|
@Matches(/^\d{10}$/, { message: 'VAT number must be exactly 10 digits' })
|
||||||
vatNumber?: string;
|
vatNumber?: string;
|
||||||
|
|
||||||
// `fanNumber` is deliberately absent: the FAN is the Fayda number of the
|
// `fanNumber` is deliberately absent: the FAN is the Fayda number of the
|
||||||
|
|||||||
@@ -177,8 +177,14 @@ export class ContractPricingService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (contract.lastMileDeliveryAddress) {
|
if (contract.lastMileDeliveryAddress) {
|
||||||
|
// New-style last-mile rates (PER_TON_KM / distance-banded PER_KM) are
|
||||||
|
// priced operationally per job, not as a single contract unit price.
|
||||||
const lm = liveRates.find(
|
const lm = liveRates.find(
|
||||||
(r) => r.rateType === 'LAST_MILE' && r.currency === 'USD',
|
(r) =>
|
||||||
|
r.rateType === 'LAST_MILE' &&
|
||||||
|
r.currency === 'USD' &&
|
||||||
|
r.rateUnit !== 'PER_TON_KM' &&
|
||||||
|
r.minKm == null,
|
||||||
);
|
);
|
||||||
if (lm && Number(lm.rateValue) > 0) {
|
if (lm && Number(lm.rateValue) > 0) {
|
||||||
lineItems.push({
|
lineItems.push({
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { FindOptionsWhere, In, IsNull, Not } from 'typeorm';
|
|||||||
import { InjectDataSource } from '@nestjs/typeorm';
|
import { InjectDataSource } from '@nestjs/typeorm';
|
||||||
import { DataSource } from 'typeorm';
|
import { DataSource } from 'typeorm';
|
||||||
import { attachMileFinancials } from '../../common/mile-financials.util';
|
import { attachMileFinancials } from '../../common/mile-financials.util';
|
||||||
|
import { estimateMileKm } from '../../common/mile-distance.util';
|
||||||
import { VehicleAvailability } from '../vehicles/entities/vehicle.entity';
|
import { VehicleAvailability } from '../vehicles/entities/vehicle.entity';
|
||||||
import { BookingsRepository } from "../bookings/bookings.repository";
|
import { BookingsRepository } from "../bookings/bookings.repository";
|
||||||
import { DriversService } from "../drivers/drivers.service";
|
import { DriversService } from "../drivers/drivers.service";
|
||||||
@@ -285,7 +286,7 @@ export class FirstMileService {
|
|||||||
status: dto.status ?? "READY_TO_TRANSIT",
|
status: dto.status ?? "READY_TO_TRANSIT",
|
||||||
advancedPayment: dto.advancedPayment ?? 0,
|
advancedPayment: dto.advancedPayment ?? 0,
|
||||||
remainingPayment: dto.remainingPayment ?? 0,
|
remainingPayment: dto.remainingPayment ?? 0,
|
||||||
estimatedKm: dto.estimatedKm ?? null,
|
estimatedKm: dto.estimatedKm ?? (await estimateMileKm(this.dataSource, dto.bookingId, 'FIRST')),
|
||||||
exactKm: dto.exactKm ?? null,
|
exactKm: dto.exactKm ?? null,
|
||||||
vehicleId: dto.vehicleId ?? null,
|
vehicleId: dto.vehicleId ?? null,
|
||||||
paid: (dto as any).paid ?? false,
|
paid: (dto as any).paid ?? false,
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import { Transform } from 'class-transformer';
|
||||||
|
import { IsNumber, Min } from 'class-validator';
|
||||||
|
|
||||||
|
export class ApproveLastMileRequestDto {
|
||||||
|
// The approve dialog prefills this from GET :id/price-estimate (rule-based),
|
||||||
|
// but the chief can still override — the typed value is what's invoiced.
|
||||||
|
@ApiProperty({ description: 'Advance amount the customer must pay before execution proceeds', example: 3000 })
|
||||||
|
@Transform(({ value }) => Number(value))
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0.01)
|
||||||
|
advanceAmount!: number;
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import { IsNotEmpty, IsString, MaxLength } from 'class-validator';
|
||||||
|
|
||||||
|
export class RejectLastMileRequestDto {
|
||||||
|
@ApiProperty({ description: 'Why the request is rejected (e.g. no truck available)' })
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
@MaxLength(500)
|
||||||
|
reason!: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { IsNotEmpty, IsOptional, IsString, MaxLength } from 'class-validator';
|
||||||
|
|
||||||
|
export class SignLastMileContractDto {
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description:
|
||||||
|
'Signature PNG as base64 (data URI or raw). Omitted = reuse the saved profile signature.',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
signatureImageBase64?: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: 'Name shown under the signature.' })
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
@MaxLength(160)
|
||||||
|
signerDisplayName!: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'The consent statement the customer agreed to.' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(500)
|
||||||
|
consentText?: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import { ArrayNotEmpty, ArrayUnique, IsArray, IsDateString, IsString } from 'class-validator';
|
||||||
|
|
||||||
|
export class SubmitLastMileRequestDto {
|
||||||
|
@ApiProperty({
|
||||||
|
type: [String],
|
||||||
|
description:
|
||||||
|
'Container numbers the customer wants delivered via EDR last-mile — pass every booking container to select "all".',
|
||||||
|
})
|
||||||
|
@IsArray()
|
||||||
|
@ArrayNotEmpty()
|
||||||
|
@ArrayUnique()
|
||||||
|
@IsString({ each: true })
|
||||||
|
containerNumbers!: string[];
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description:
|
||||||
|
'Requested last-mile delivery date (ISO date), chosen by the customer against the train departure from Djibouti.',
|
||||||
|
example: '2026-08-15',
|
||||||
|
})
|
||||||
|
@IsDateString()
|
||||||
|
deliveryDate!: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import { BaseEntity } from '@edr/api-common';
|
||||||
|
import { LastMileRequestStatus } from '@edr/types';
|
||||||
|
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
|
||||||
|
|
||||||
|
import { Booking } from '../../bookings/entities/booking.entity';
|
||||||
|
import { TrainSchedule } from '../../train-schedules/entities/train-schedule.entity';
|
||||||
|
import { LastMile } from '../../last-mile/entities/last-mile.entity';
|
||||||
|
|
||||||
|
export const LAST_MILE_REQUEST_STATUSES = [
|
||||||
|
LastMileRequestStatus.AwaitingConfirmation,
|
||||||
|
LastMileRequestStatus.Submitted,
|
||||||
|
LastMileRequestStatus.Approved,
|
||||||
|
LastMileRequestStatus.Rejected,
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The pre-approval confirmation stage in front of `LastMile`: fired when a
|
||||||
|
* train departs Djibouti, filled by the customer, reviewed by the Truck &
|
||||||
|
* Machinery chief. One row per (bookingId, trainScheduleId) — a booking whose
|
||||||
|
* containers arrive across several departures gets a request per departure.
|
||||||
|
*/
|
||||||
|
@Entity({ name: 'last_mile_requests', schema: 'freight' })
|
||||||
|
@Index(['bookingId'])
|
||||||
|
@Index(['status'])
|
||||||
|
export class LastMileRequest extends BaseEntity {
|
||||||
|
@Column({ name: 'booking_id', type: 'uuid' })
|
||||||
|
bookingId!: string;
|
||||||
|
|
||||||
|
@ManyToOne(() => Booking, { nullable: false, eager: false })
|
||||||
|
@JoinColumn({ name: 'booking_id' })
|
||||||
|
booking?: Booking;
|
||||||
|
|
||||||
|
@Column({ name: 'train_schedule_id', type: 'uuid' })
|
||||||
|
trainScheduleId!: string;
|
||||||
|
|
||||||
|
@ManyToOne(() => TrainSchedule, { nullable: false, eager: false })
|
||||||
|
@JoinColumn({ name: 'train_schedule_id' })
|
||||||
|
trainSchedule?: TrainSchedule;
|
||||||
|
|
||||||
|
@Column({ name: 'status', type: 'varchar', length: 30, default: LastMileRequestStatus.AwaitingConfirmation })
|
||||||
|
status!: LastMileRequestStatus;
|
||||||
|
|
||||||
|
/** Customer's container selection — "all" is just every booking container listed here. */
|
||||||
|
@Column({ name: 'requested_container_numbers', type: 'text', array: true, nullable: true })
|
||||||
|
requestedContainerNumbers?: string[] | null;
|
||||||
|
|
||||||
|
@Column({ name: 'reminder_sent_at', type: 'timestamptz', nullable: true })
|
||||||
|
reminderSentAt?: Date | null;
|
||||||
|
|
||||||
|
@Column({ name: 'submitted_by_user_id', type: 'uuid', nullable: true })
|
||||||
|
submittedByUserId?: string | null;
|
||||||
|
|
||||||
|
@Column({ name: 'submitted_at', type: 'timestamptz', nullable: true })
|
||||||
|
submittedAt?: Date | null;
|
||||||
|
|
||||||
|
@Column({ name: 'reviewed_by_staff_id', type: 'uuid', nullable: true })
|
||||||
|
reviewedByStaffId?: string | null;
|
||||||
|
|
||||||
|
@Column({ name: 'reviewed_at', type: 'timestamptz', nullable: true })
|
||||||
|
reviewedAt?: Date | null;
|
||||||
|
|
||||||
|
@Column({ name: 'rejection_reason', type: 'text', nullable: true })
|
||||||
|
rejectionReason?: string | null;
|
||||||
|
|
||||||
|
@Column({ name: 'resulting_last_mile_id', type: 'uuid', nullable: true })
|
||||||
|
resultingLastMileId?: string | null;
|
||||||
|
|
||||||
|
/** Customer-chosen last-mile delivery date (guided by the train's Djibouti departure). */
|
||||||
|
@Column({ name: 'requested_delivery_date', type: 'date', nullable: true })
|
||||||
|
requestedDeliveryDate?: string | null;
|
||||||
|
|
||||||
|
/** Chief-approved advance — invoiced only after the customer signs the LM contract. */
|
||||||
|
@Column({
|
||||||
|
name: 'approved_advance_amount',
|
||||||
|
type: 'numeric',
|
||||||
|
precision: 14,
|
||||||
|
scale: 2,
|
||||||
|
nullable: true,
|
||||||
|
transformer: {
|
||||||
|
to: (v?: number | null) => v,
|
||||||
|
from: (v?: string | null) => (v == null ? null : Number(v)),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
approvedAdvanceAmount?: number | null;
|
||||||
|
|
||||||
|
/** Rate snapshot taken at approval, rendered into the contract document. */
|
||||||
|
@Column({ name: 'contract_summary', type: 'jsonb', nullable: true })
|
||||||
|
contractSummary?: {
|
||||||
|
estimatedKm: number | null;
|
||||||
|
mode: string | null;
|
||||||
|
currency: string | null;
|
||||||
|
total: number | null;
|
||||||
|
lines: Array<{ description: string; amount: number }>;
|
||||||
|
advanceAmount: number;
|
||||||
|
} | null;
|
||||||
|
|
||||||
|
@Column({ name: 'contract_generated_at', type: 'timestamptz', nullable: true })
|
||||||
|
contractGeneratedAt?: Date | null;
|
||||||
|
|
||||||
|
@Column({ name: 'customer_signed_at', type: 'timestamptz', nullable: true })
|
||||||
|
customerSignedAt?: Date | null;
|
||||||
|
|
||||||
|
@Column({ name: 'signer_display_name', type: 'varchar', length: 160, nullable: true })
|
||||||
|
signerDisplayName?: string | null;
|
||||||
|
|
||||||
|
@Column({ name: 'consent_text', type: 'text', nullable: true })
|
||||||
|
consentText?: string | null;
|
||||||
|
|
||||||
|
@ManyToOne(() => LastMile, { nullable: true, eager: false })
|
||||||
|
@JoinColumn({ name: 'resulting_last_mile_id' })
|
||||||
|
resultingLastMile?: LastMile | null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,318 @@
|
|||||||
|
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
import Handlebars from 'handlebars';
|
||||||
|
import { Readable } from 'stream';
|
||||||
|
import { DataSource } from 'typeorm';
|
||||||
|
import { LastMileRequestStatus } from '@edr/types';
|
||||||
|
|
||||||
|
import { ContractPdfService } from '../../contracts/contract-pdf.service';
|
||||||
|
import { Booking } from '../bookings/entities/booking.entity';
|
||||||
|
import { BookingsService } from '../bookings/bookings.service';
|
||||||
|
import { FilesService } from '../files/files.service';
|
||||||
|
import { FileRecord } from '../files/entities/file.entity';
|
||||||
|
import { MinioService } from '../minio/minio.service';
|
||||||
|
import { SignaturesService } from '../signatures/signatures.service';
|
||||||
|
import { SignLastMileContractDto } from './dto/sign-last-mile-contract.dto';
|
||||||
|
import { LastMileRequest } from './entities/last-mile-request.entity';
|
||||||
|
import { LastMileRequestsRepository } from './last-mile-requests.repository';
|
||||||
|
import { LastMileRequestsService } from './last-mile-requests.service';
|
||||||
|
|
||||||
|
const FILE_RESOURCE = 'last_mile_requests';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The LM contract in front of the advance payment: generated when the chief
|
||||||
|
* approves the request, viewed and signed by the customer in the portal, and
|
||||||
|
* only then invoiced (LastMileRequestsService.generateAdvanceInvoice). Single
|
||||||
|
* signer (customer), so the signature lives on the request row itself — no
|
||||||
|
* signature-rows table like bookings/CRSP contracts need for multi-role.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class LastMileContractService {
|
||||||
|
private readonly logger = new Logger(LastMileContractService.name);
|
||||||
|
private compiledTemplate: Handlebars.TemplateDelegate | null = null;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly requestsRepository: LastMileRequestsRepository,
|
||||||
|
private readonly requestsService: LastMileRequestsService,
|
||||||
|
private readonly bookingsService: BookingsService,
|
||||||
|
private readonly filesService: FilesService,
|
||||||
|
private readonly minioService: MinioService,
|
||||||
|
private readonly pdfService: ContractPdfService,
|
||||||
|
private readonly signaturesService: SignaturesService,
|
||||||
|
private readonly dataSource: DataSource,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async getContractView(id: string, viewerUserId?: string | null) {
|
||||||
|
const request = await this.requireApprovedRequest(id);
|
||||||
|
const booking = await this.requireBooking(request);
|
||||||
|
const view = await this.buildViewModel(request, booking);
|
||||||
|
const html = this.render(view);
|
||||||
|
const savedSignature = viewerUserId
|
||||||
|
? ((await this.signaturesService.getForUser(viewerUserId)) ?? undefined)
|
||||||
|
: undefined;
|
||||||
|
return {
|
||||||
|
requestId: request.id,
|
||||||
|
bookingId: request.bookingId,
|
||||||
|
bookingReference: booking.reference,
|
||||||
|
status: request.status,
|
||||||
|
html,
|
||||||
|
customerSignedAt: request.customerSignedAt ?? null,
|
||||||
|
signerDisplayName: request.signerDisplayName ?? null,
|
||||||
|
canSign: !request.customerSignedAt,
|
||||||
|
savedSignature,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async streamContract(id: string) {
|
||||||
|
const request = await this.requireApprovedRequest(id);
|
||||||
|
const booking = await this.requireBooking(request);
|
||||||
|
const record = await this.upsertContractPdf(request, booking);
|
||||||
|
return this.filesService.streamById(record.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async sign(
|
||||||
|
id: string,
|
||||||
|
dto: SignLastMileContractDto,
|
||||||
|
signerUserId: string | null,
|
||||||
|
): Promise<LastMileRequest> {
|
||||||
|
const request = await this.requireApprovedRequest(id);
|
||||||
|
if (request.customerSignedAt) {
|
||||||
|
throw new BadRequestException('This last-mile contract is already signed');
|
||||||
|
}
|
||||||
|
const booking = await this.requireBooking(request);
|
||||||
|
|
||||||
|
if (signerUserId) {
|
||||||
|
const companyId = await this.bookingsService.resolveCustomerCompanyId(signerUserId);
|
||||||
|
if (companyId && booking.companyId && companyId !== booking.companyId) {
|
||||||
|
throw new BadRequestException('This request does not belong to your company');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drawn signature wins; otherwise fall back to the saved profile signature
|
||||||
|
// (same contract-signing convention as modules/contracts).
|
||||||
|
let imageBase64 = dto.signatureImageBase64;
|
||||||
|
if (!imageBase64 && signerUserId) {
|
||||||
|
const saved = await this.signaturesService.getForUser(signerUserId);
|
||||||
|
if (saved?.signatureImageUrl?.startsWith('data:')) {
|
||||||
|
imageBase64 = saved.signatureImageUrl;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!imageBase64) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'No signature image provided and no saved signature on your profile',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const buffer = this.decodeSignatureImage(imageBase64);
|
||||||
|
const sigFile = this.toUploadFile(
|
||||||
|
`signature-customer-${booking.reference ?? request.id}.png`,
|
||||||
|
'image/png',
|
||||||
|
buffer,
|
||||||
|
);
|
||||||
|
const fileRecord = await this.filesService.upsertByCode({
|
||||||
|
resourceId: request.id,
|
||||||
|
resource: FILE_RESOURCE,
|
||||||
|
code: 'signature_customer',
|
||||||
|
file: sigFile,
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.requestsRepository.update(id, {
|
||||||
|
customerSignedAt: new Date(),
|
||||||
|
signerDisplayName: dto.signerDisplayName,
|
||||||
|
consentText: dto.consentText ?? null,
|
||||||
|
} as Partial<LastMileRequest>);
|
||||||
|
|
||||||
|
// Best-effort: keep the reusable profile signature fresh for next time.
|
||||||
|
if (signerUserId && dto.signatureImageBase64) {
|
||||||
|
try {
|
||||||
|
await this.signaturesService.upsertForUser({
|
||||||
|
userId: signerUserId,
|
||||||
|
signerDisplayName: dto.signerDisplayName,
|
||||||
|
signatureImageBase64: dto.signatureImageBase64,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(`Could not save reusable signature for user ${signerUserId}: ${err}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const signed = (await this.requestsRepository.findById(id, {
|
||||||
|
relations: { booking: { company: true } },
|
||||||
|
}))!;
|
||||||
|
|
||||||
|
// Render + store the signed PDF, then invoice the advance. PDF failure must
|
||||||
|
// not block the invoice — the document re-renders on view/download.
|
||||||
|
try {
|
||||||
|
await this.upsertContractPdf(signed, booking, fileRecord);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(
|
||||||
|
`Signed LM contract PDF deferred for ${booking.reference}: ${err}. It will render on view/download.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await this.requestsService.generateAdvanceInvoice(signed);
|
||||||
|
|
||||||
|
return signed;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async upsertContractPdf(
|
||||||
|
request: LastMileRequest,
|
||||||
|
booking: Booking,
|
||||||
|
signatureRecord?: FileRecord,
|
||||||
|
): Promise<FileRecord> {
|
||||||
|
const view = await this.buildViewModel(request, booking, signatureRecord);
|
||||||
|
const html = this.render(view);
|
||||||
|
const pdfBuffer = await this.pdfService.htmlToPdfBuffer(html);
|
||||||
|
const companyName = booking.company?.name ?? 'Customer';
|
||||||
|
const fileName = `LM_${companyName.replace(/[^A-Za-z0-9._-]+/g, '_')}.pdf`;
|
||||||
|
const file = this.toUploadFile(fileName, 'application/pdf', pdfBuffer);
|
||||||
|
return this.filesService.upsertByCode({
|
||||||
|
resourceId: request.id,
|
||||||
|
resource: FILE_RESOURCE,
|
||||||
|
code: 'contract',
|
||||||
|
file,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async buildViewModel(
|
||||||
|
request: LastMileRequest,
|
||||||
|
booking: Booking,
|
||||||
|
signatureRecord?: FileRecord,
|
||||||
|
) {
|
||||||
|
const summary = request.contractSummary;
|
||||||
|
const containers = request.requestedContainerNumbers ?? [];
|
||||||
|
const cargoDescription =
|
||||||
|
booking.cargoFreeText || booking.cargoType?.cargoTypeName || null;
|
||||||
|
|
||||||
|
const departedRows: Array<{ departedAt: Date | null }> = await this.dataSource.query(
|
||||||
|
`SELECT departed_from_djibouti_at AS "departedAt"
|
||||||
|
FROM freight.import_djibouti_operations
|
||||||
|
WHERE train_schedule_id = $1 AND deleted_at IS NULL
|
||||||
|
LIMIT 1`,
|
||||||
|
[request.trainScheduleId],
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
companyName: booking.company?.name ?? 'Customer',
|
||||||
|
bookingReference: booking.reference ?? request.bookingId,
|
||||||
|
containerCount: containers.length || null,
|
||||||
|
containerList: containers.join(', '),
|
||||||
|
cargoDescription,
|
||||||
|
deliveryAddress: booking.lastMileDeliveryAddress ?? null,
|
||||||
|
trainDepartureDate: this.formatDate(departedRows[0]?.departedAt),
|
||||||
|
deliveryDate: this.formatDate(request.requestedDeliveryDate) ?? '—',
|
||||||
|
requestDate: this.formatDate(request.submittedAt ?? request.reminderSentAt ?? request.createdAt) ?? '—',
|
||||||
|
approvalDate: this.formatDate(request.reviewedAt) ?? '—',
|
||||||
|
currency: summary?.currency ?? booking.paymentCurrency ?? 'ETB',
|
||||||
|
rateLines: (summary?.lines ?? []).map((l) => ({
|
||||||
|
description: l.description,
|
||||||
|
amount: this.formatAmount(l.amount),
|
||||||
|
})),
|
||||||
|
estimatedKm: summary?.estimatedKm ?? null,
|
||||||
|
advanceAmount: this.formatAmount(
|
||||||
|
summary?.advanceAmount ?? request.approvedAdvanceAmount ?? 0,
|
||||||
|
),
|
||||||
|
signature: request.customerSignedAt
|
||||||
|
? {
|
||||||
|
signerDisplayName: request.signerDisplayName ?? '',
|
||||||
|
signedAt: this.formatDate(request.customerSignedAt) ?? '',
|
||||||
|
consentText: request.consentText ?? null,
|
||||||
|
imageUrl: await this.signatureImageDataUri(request, signatureRecord),
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Signature PNG as a data URI so the PDF renderer needs no MinIO access. */
|
||||||
|
private async signatureImageDataUri(
|
||||||
|
request: LastMileRequest,
|
||||||
|
signatureRecord?: FileRecord,
|
||||||
|
): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const record =
|
||||||
|
signatureRecord ??
|
||||||
|
(await this.filesService.findByCode(request.id, FILE_RESOURCE, 'signature_customer'));
|
||||||
|
if (!record.url) return null;
|
||||||
|
const objectName = this.minioService.getObjectNameFromUrl(record.url);
|
||||||
|
const stream = await this.minioService.getFileStream(objectName);
|
||||||
|
const buffer = await this.streamToBuffer(stream);
|
||||||
|
return `data:image/png;base64,${buffer.toString('base64')}`;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private render(view: Record<string, unknown>): string {
|
||||||
|
if (!this.compiledTemplate) {
|
||||||
|
const source = fs.readFileSync(
|
||||||
|
path.join(__dirname, '..', '..', 'contracts', 'templates', 'last-mile.hbs'),
|
||||||
|
'utf-8',
|
||||||
|
);
|
||||||
|
this.compiledTemplate = Handlebars.compile(source);
|
||||||
|
}
|
||||||
|
return this.compiledTemplate(view);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async requireApprovedRequest(id: string): Promise<LastMileRequest> {
|
||||||
|
const request = await this.requestsService.findById(id);
|
||||||
|
if (request.status !== LastMileRequestStatus.Approved) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`The last-mile contract is available once the request is approved (current status: ${request.status})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return request;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async requireBooking(request: LastMileRequest): Promise<Booking> {
|
||||||
|
const booking = await this.dataSource.manager.findOne(Booking, {
|
||||||
|
where: { id: request.bookingId },
|
||||||
|
relations: { company: true, cargoType: true },
|
||||||
|
});
|
||||||
|
if (!booking) throw new BadRequestException(`Booking ${request.bookingId} not found`);
|
||||||
|
return booking;
|
||||||
|
}
|
||||||
|
|
||||||
|
private formatDate(value?: Date | string | null): string | null {
|
||||||
|
if (!value) return null;
|
||||||
|
const date = value instanceof Date ? value : new Date(value);
|
||||||
|
if (Number.isNaN(date.getTime())) return null;
|
||||||
|
return date.toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
private formatAmount(value: number): string {
|
||||||
|
return Number(value).toLocaleString('en-US', {
|
||||||
|
minimumFractionDigits: 2,
|
||||||
|
maximumFractionDigits: 2,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private toUploadFile(name: string, mimetype: string, buffer: Buffer): Express.Multer.File {
|
||||||
|
return {
|
||||||
|
fieldname: 'file',
|
||||||
|
originalname: name,
|
||||||
|
encoding: '7bit',
|
||||||
|
mimetype,
|
||||||
|
size: buffer.length,
|
||||||
|
buffer,
|
||||||
|
stream: Readable.from(buffer),
|
||||||
|
destination: '',
|
||||||
|
filename: '',
|
||||||
|
path: '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private decodeSignatureImage(base64: string): Buffer {
|
||||||
|
const raw = base64.includes(',') ? base64.split(',')[1]! : base64;
|
||||||
|
return Buffer.from(raw, 'base64');
|
||||||
|
}
|
||||||
|
|
||||||
|
private streamToBuffer(stream: Readable): Promise<Buffer> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
stream.on('data', (chunk: Buffer | string) => {
|
||||||
|
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
||||||
|
});
|
||||||
|
stream.on('error', reject);
|
||||||
|
stream.on('end', () => resolve(Buffer.concat(chunks)));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
import { Body, Controller, Get, Param, ParseUUIDPipe, Post, Query, Res } from '@nestjs/common';
|
||||||
|
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
|
import type { Response } from 'express';
|
||||||
|
|
||||||
|
import { CurrentUser } from '@edr/api-common';
|
||||||
|
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
|
||||||
|
import { LastMileRequestStatus } from '@edr/types';
|
||||||
|
|
||||||
|
import { BookingStaff } from '../../common/booking-guards';
|
||||||
|
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
||||||
|
import { ApproveLastMileRequestDto } from './dto/approve-last-mile-request.dto';
|
||||||
|
import { RejectLastMileRequestDto } from './dto/reject-last-mile-request.dto';
|
||||||
|
import { SignLastMileContractDto } from './dto/sign-last-mile-contract.dto';
|
||||||
|
import { SubmitLastMileRequestDto } from './dto/submit-last-mile-request.dto';
|
||||||
|
import { LastMileContractService } from './last-mile-contract.service';
|
||||||
|
import { LastMileRequestsService } from './last-mile-requests.service';
|
||||||
|
|
||||||
|
@ApiTags('last-mile-requests')
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@Controller('last-mile-requests')
|
||||||
|
export class LastMileRequestsController {
|
||||||
|
constructor(
|
||||||
|
private readonly requestsService: LastMileRequestsService,
|
||||||
|
private readonly contractService: LastMileContractService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@BookingStaff(FREIGHT_PERMS.lastMile.requestView)
|
||||||
|
@ApiOperation({ summary: 'List last-mile confirmation requests' })
|
||||||
|
findAll(
|
||||||
|
@Query('status') status?: string,
|
||||||
|
@Query('bookingId') bookingId?: string,
|
||||||
|
@Query('page') page?: string,
|
||||||
|
@Query('pageSize') pageSize?: string,
|
||||||
|
) {
|
||||||
|
return this.requestsService.findAll({
|
||||||
|
status: status as LastMileRequestStatus | undefined,
|
||||||
|
bookingId,
|
||||||
|
page: page ? parseInt(page, 10) : undefined,
|
||||||
|
pageSize: pageSize ? parseInt(pageSize, 10) : undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('free-truck-count')
|
||||||
|
@BookingStaff(FREIGHT_PERMS.lastMile.requestView)
|
||||||
|
@ApiOperation({ summary: 'Free (ACTIVE + unassigned) trucks — informational context for approval' })
|
||||||
|
freeTruckCount() {
|
||||||
|
return this.requestsService.freeTruckCount().then((count) => ({ count }));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id/price-estimate')
|
||||||
|
@BookingStaff(FREIGHT_PERMS.lastMile.requestView)
|
||||||
|
@ApiOperation({
|
||||||
|
summary:
|
||||||
|
'Rule-based last-mile price estimate (estimated km × live last-mile rates) — informational context for approval',
|
||||||
|
})
|
||||||
|
priceEstimate(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
|
return this.requestsService.priceEstimate(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Customer-facing like :id/submit — the service ownership-checks against the
|
||||||
|
// resolved company; staff may also open it (read-only view).
|
||||||
|
@Get(':id/contract/view')
|
||||||
|
@ApiOperation({ summary: 'LM contract view model + rendered HTML + saved signature' })
|
||||||
|
contractView(@Param('id', ParseUUIDPipe) id: string, @CurrentUser() user: TCurrentUser) {
|
||||||
|
return this.contractService.getContractView(id, user?.id ?? null);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id/contract/document')
|
||||||
|
@ApiOperation({ summary: 'Download the LM contract PDF (LM_<CustomerName>.pdf)' })
|
||||||
|
async contractDocument(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Res() res: Response,
|
||||||
|
): Promise<void> {
|
||||||
|
const { stream, record } = await this.contractService.streamContract(id);
|
||||||
|
res.setHeader('Content-Type', record.mimeType ?? 'application/pdf');
|
||||||
|
res.setHeader('Content-Disposition', `attachment; filename="${record.name}"`);
|
||||||
|
stream.pipe(res);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/contract/sign')
|
||||||
|
@ApiOperation({ summary: 'Customer agrees and signs the LM contract — then the advance invoice is issued' })
|
||||||
|
signContract(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Body() dto: SignLastMileContractDto,
|
||||||
|
@CurrentUser() user: TCurrentUser,
|
||||||
|
) {
|
||||||
|
return this.contractService.sign(id, dto, user?.id ?? null);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id')
|
||||||
|
@BookingStaff(FREIGHT_PERMS.lastMile.requestView)
|
||||||
|
@ApiOperation({ summary: 'Get a last-mile confirmation request by ID' })
|
||||||
|
findOne(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
|
return this.requestsService.findById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// No @BookingStaff — the customer (portal) fills this, not backoffice staff.
|
||||||
|
// TODO: integrate @edr/auth — @CurrentUser is a stub until then; the service
|
||||||
|
// still cross-checks the request's booking against the resolved company.
|
||||||
|
@Post(':id/submit')
|
||||||
|
@ApiOperation({ summary: "Customer confirms which containers go via EDR last-mile" })
|
||||||
|
submit(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Body() dto: SubmitLastMileRequestDto,
|
||||||
|
@CurrentUser() user: TCurrentUser,
|
||||||
|
) {
|
||||||
|
return this.requestsService.submit(id, user?.id ?? null, dto.containerNumbers, dto.deliveryDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/approve')
|
||||||
|
@BookingStaff(FREIGHT_PERMS.lastMile.requestApprove)
|
||||||
|
@ApiOperation({ summary: 'Truck & Machinery chief approves the request — LM contract becomes signable; the advance invoice follows the customer signature' })
|
||||||
|
approve(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Body() dto: ApproveLastMileRequestDto,
|
||||||
|
@CurrentUser() user: TCurrentUser,
|
||||||
|
) {
|
||||||
|
return this.requestsService.approve(id, user?.id ?? null, dto.advanceAmount);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/reject')
|
||||||
|
@BookingStaff(FREIGHT_PERMS.lastMile.requestApprove)
|
||||||
|
@ApiOperation({ summary: 'Truck & Machinery chief rejects the request with a reason' })
|
||||||
|
reject(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Body() dto: RejectLastMileRequestDto,
|
||||||
|
@CurrentUser() user: TCurrentUser,
|
||||||
|
) {
|
||||||
|
return this.requestsService.reject(id, user?.id ?? null, dto.reason);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { Module, forwardRef } from '@nestjs/common';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
|
||||||
|
import { ContractPdfService } from '../../contracts/contract-pdf.service';
|
||||||
|
import { BillingModule } from '../billing/billing.module';
|
||||||
|
import { BookingsModule } from '../bookings/bookings.module';
|
||||||
|
import { FilesModule } from '../files/files.module';
|
||||||
|
import { LastMileModule } from '../last-mile/last-mile.module';
|
||||||
|
import { MinioModule } from '../minio/minio.module';
|
||||||
|
import { NotificationInboxModule } from '../notification-inbox/notification-inbox.module';
|
||||||
|
import { SignaturesModule } from '../signatures/signatures.module';
|
||||||
|
import { LastMileRequest } from './entities/last-mile-request.entity';
|
||||||
|
import { LastMileContractService } from './last-mile-contract.service';
|
||||||
|
import { LastMileRequestsController } from './last-mile-requests.controller';
|
||||||
|
import { LastMileRequestsRepository } from './last-mile-requests.repository';
|
||||||
|
import { LastMileRequestsService } from './last-mile-requests.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
TypeOrmModule.forFeature([LastMileRequest]),
|
||||||
|
BillingModule,
|
||||||
|
forwardRef(() => BookingsModule),
|
||||||
|
LastMileModule,
|
||||||
|
NotificationInboxModule,
|
||||||
|
FilesModule,
|
||||||
|
MinioModule,
|
||||||
|
SignaturesModule,
|
||||||
|
],
|
||||||
|
controllers: [LastMileRequestsController],
|
||||||
|
providers: [
|
||||||
|
LastMileRequestsRepository,
|
||||||
|
LastMileRequestsService,
|
||||||
|
LastMileContractService,
|
||||||
|
ContractPdfService,
|
||||||
|
],
|
||||||
|
exports: [LastMileRequestsService],
|
||||||
|
})
|
||||||
|
export class LastMileRequestsModule {}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
import { BaseRepository } from '@edr/api-common';
|
||||||
|
|
||||||
|
import { LastMileRequest } from './entities/last-mile-request.entity';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class LastMileRequestsRepository extends BaseRepository<LastMileRequest> {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(LastMileRequest)
|
||||||
|
repository: Repository<LastMileRequest>,
|
||||||
|
) {
|
||||||
|
super(repository);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,421 @@
|
|||||||
|
import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||||
|
import { Cron } from '@nestjs/schedule';
|
||||||
|
import { DataSource, FindOptionsWhere } from 'typeorm';
|
||||||
|
import { Freight, LastMileRequestStatus } from '@edr/types';
|
||||||
|
|
||||||
|
import {
|
||||||
|
LastMileCharge,
|
||||||
|
computeLastMileCharge,
|
||||||
|
lastMileShipmentShape,
|
||||||
|
} from '../../common/last-mile-charge.util';
|
||||||
|
import { estimateMileKm } from '../../common/mile-distance.util';
|
||||||
|
import { usesEdrMileService } from '../../common/mile-haulage.util';
|
||||||
|
import { RatesService } from '../rule-engine/services/rates.service';
|
||||||
|
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
||||||
|
import { BookingsRepository } from '../bookings/bookings.repository';
|
||||||
|
import { BookingsService } from '../bookings/bookings.service';
|
||||||
|
import { Booking } from '../bookings/entities/booking.entity';
|
||||||
|
import { BillingService } from '../billing/billing.service';
|
||||||
|
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
|
||||||
|
import { NotificationAudience, NotificationPriority, NotificationType } from '@edr/types';
|
||||||
|
import { LastMileService } from '../last-mile/last-mile.service';
|
||||||
|
import { Vehicle, VehicleAvailability, VehicleStatus } from '../vehicles/entities/vehicle.entity';
|
||||||
|
import { LastMileRequest } from './entities/last-mile-request.entity';
|
||||||
|
import { LastMileRequestsRepository } from './last-mile-requests.repository';
|
||||||
|
|
||||||
|
type ListFilter = {
|
||||||
|
status?: LastMileRequestStatus;
|
||||||
|
bookingId?: string;
|
||||||
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Just what remind() needs off a departed schedule — deliberately not the full
|
||||||
|
* `TrainSchedule` entity so this module never has to import train-scheduling code. */
|
||||||
|
type DepartedSchedule = { id: string; trainNumber?: string | null };
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class LastMileRequestsService {
|
||||||
|
private readonly logger = new Logger(LastMileRequestsService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly requestsRepository: LastMileRequestsRepository,
|
||||||
|
private readonly bookingsRepository: BookingsRepository,
|
||||||
|
private readonly bookingsService: BookingsService,
|
||||||
|
private readonly lastMileService: LastMileService,
|
||||||
|
private readonly billing: BillingService,
|
||||||
|
private readonly notifications: NotificationInboxService,
|
||||||
|
private readonly ratesService: RatesService,
|
||||||
|
private readonly dataSource: DataSource,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/** Container numbers on the booking (upper-cased) — mirrors LastMileService's own helper. */
|
||||||
|
private async bookingContainerNumbers(bookingId: string): Promise<string[]> {
|
||||||
|
const rows: Array<{ containerNumber: string }> = await this.dataSource.query(
|
||||||
|
`SELECT bcu.container_number AS "containerNumber"
|
||||||
|
FROM freight.booking_container_units bcu
|
||||||
|
JOIN freight.booking_container bc
|
||||||
|
ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL
|
||||||
|
WHERE bc.booking_id = $1 AND bcu.deleted_at IS NULL`,
|
||||||
|
[bookingId],
|
||||||
|
);
|
||||||
|
return rows.map((r) => r.containerNumber.trim().toUpperCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Poll for trains that have departed Djibouti and remind their eligible
|
||||||
|
* bookings. Deliberately a self-contained poller (raw SQL against
|
||||||
|
* `import_djibouti_operations`/`train_schedules`, no import of train-scheduling
|
||||||
|
* module code) rather than a hook inside `TrainSchedulingService.dispatchSchedule`
|
||||||
|
* — keeps this feature decoupled from that module entirely. `remindForDeparture`
|
||||||
|
* is idempotent per (bookingId, scheduleId), so re-scanning the same recent
|
||||||
|
* window on every tick is safe — a schedule already fully reminded is a no-op.
|
||||||
|
*/
|
||||||
|
@Cron('*/2 * * * *', { name: 'last-mile-request-departure-scan' })
|
||||||
|
async scanDepartedSchedules(): Promise<void> {
|
||||||
|
let schedules: DepartedSchedule[] = [];
|
||||||
|
try {
|
||||||
|
schedules = await this.dataSource.query(
|
||||||
|
`SELECT ts.id AS "id", ts.train_number AS "trainNumber"
|
||||||
|
FROM freight.import_djibouti_operations op
|
||||||
|
JOIN freight.train_schedules ts
|
||||||
|
ON ts.id = op.train_schedule_id AND ts.deleted_at IS NULL
|
||||||
|
WHERE op.deleted_at IS NULL
|
||||||
|
AND op.departed_from_djibouti_at IS NOT NULL
|
||||||
|
AND op.departed_from_djibouti_at > now() - interval '14 days'`,
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(`Failed to scan for departed schedules: ${(err as Error).message}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const schedule of schedules) {
|
||||||
|
await this.remindForDeparture(schedule);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fired for a train that has departed Djibouti (import direction). For every booking
|
||||||
|
* already loaded on this schedule that bought EDR last-mile, idempotently
|
||||||
|
* creates the AWAITING_CONFIRMATION request and reminds both the customer and
|
||||||
|
* the Truck & Machinery department. Fire-and-forget per booking — one bad
|
||||||
|
* booking must never block the rest of the departure notification.
|
||||||
|
*/
|
||||||
|
async remindForDeparture(schedule: DepartedSchedule): Promise<void> {
|
||||||
|
let bookingIds: string[] = [];
|
||||||
|
try {
|
||||||
|
const rows: Array<{ bookingId: string }> = await this.dataSource.query(
|
||||||
|
`SELECT booking_id AS "bookingId"
|
||||||
|
FROM freight.train_schedule_bookings
|
||||||
|
WHERE train_schedule_id = $1 AND loading_status = 'LOADED' AND deleted_at IS NULL`,
|
||||||
|
[schedule.id],
|
||||||
|
);
|
||||||
|
bookingIds = rows.map((r) => r.bookingId);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(`Failed to load schedule bookings for ${schedule.id}: ${(err as Error).message}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!bookingIds.length) return;
|
||||||
|
|
||||||
|
for (const bookingId of bookingIds) {
|
||||||
|
try {
|
||||||
|
const booking = await this.bookingsRepository.findById(bookingId);
|
||||||
|
if (!booking) continue;
|
||||||
|
if (
|
||||||
|
!usesEdrMileService({
|
||||||
|
tradeDirection: booking.tradeDirection,
|
||||||
|
firstMile: booking.firstMilePickupAddress ?? null,
|
||||||
|
lastMile: booking.lastMileDeliveryAddress ?? null,
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
await this.remind(booking, schedule);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(`Failed to remind booking ${bookingId} for schedule ${schedule.id}: ${(err as Error).message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async remind(booking: Booking, schedule: DepartedSchedule): Promise<void> {
|
||||||
|
const [existing] = await this.requestsRepository.findAll({
|
||||||
|
where: { bookingId: booking.id, trainScheduleId: schedule.id },
|
||||||
|
take: 1,
|
||||||
|
});
|
||||||
|
if (existing) return; // already reminded for this departure
|
||||||
|
|
||||||
|
const request = await this.requestsRepository.create({
|
||||||
|
bookingId: booking.id,
|
||||||
|
trainScheduleId: schedule.id,
|
||||||
|
status: LastMileRequestStatus.AwaitingConfirmation,
|
||||||
|
reminderSentAt: new Date(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const trainLabel = schedule.trainNumber ? `train ${schedule.trainNumber}` : 'your train';
|
||||||
|
if (booking.companyId) {
|
||||||
|
void this.notifications.notify({
|
||||||
|
recipients: { companyId: booking.companyId },
|
||||||
|
audience: NotificationAudience.PORTAL,
|
||||||
|
type: NotificationType.SCHEDULE_UPDATE,
|
||||||
|
title: 'Confirm your last-mile delivery',
|
||||||
|
body: `${trainLabel} carrying booking ${booking.reference ?? booking.id} has departed Djibouti. Confirm which containers go via EDR last-mile.`,
|
||||||
|
link: `/bookings/${booking.id}/last-mile-confirm?requestId=${request.id}`,
|
||||||
|
data: { bookingId: booking.id, requestId: request.id },
|
||||||
|
priority: NotificationPriority.HIGH,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
void this.notifications.notify({
|
||||||
|
recipients: { permissionKeys: [FREIGHT_PERMS.lastMile.requestReview] },
|
||||||
|
audience: NotificationAudience.BACKOFFICE,
|
||||||
|
type: NotificationType.SCHEDULE_UPDATE,
|
||||||
|
title: 'Last-mile confirmation expected',
|
||||||
|
body: `${trainLabel} carrying booking ${booking.reference ?? booking.id} has departed Djibouti — awaiting the customer's last-mile confirmation.`,
|
||||||
|
link: `/dashboard/operations/last-mile?tab=requests`,
|
||||||
|
data: { bookingId: booking.id, requestId: request.id },
|
||||||
|
priority: NotificationPriority.HIGH,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async findAll(filter: ListFilter = {}): Promise<{
|
||||||
|
data: LastMileRequest[];
|
||||||
|
meta: { total: number; page: number; pageSize: number; totalPages: number };
|
||||||
|
}> {
|
||||||
|
const page = filter.page ?? 1;
|
||||||
|
const pageSize = filter.pageSize ?? 50;
|
||||||
|
const where: FindOptionsWhere<LastMileRequest> = {};
|
||||||
|
if (filter.status) where.status = filter.status;
|
||||||
|
if (filter.bookingId) where.bookingId = filter.bookingId;
|
||||||
|
|
||||||
|
const [data, total] = await this.requestsRepository.findAndCount({
|
||||||
|
where,
|
||||||
|
relations: { booking: { company: true } },
|
||||||
|
order: { createdAt: 'DESC' },
|
||||||
|
skip: (page - 1) * pageSize,
|
||||||
|
take: pageSize,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
data,
|
||||||
|
meta: { total, page, pageSize, totalPages: Math.max(1, Math.ceil(total / pageSize)) },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async findById(id: string): Promise<LastMileRequest> {
|
||||||
|
const record = await this.requestsRepository.findById(id, {
|
||||||
|
relations: { booking: { company: true } },
|
||||||
|
});
|
||||||
|
if (!record) throw new NotFoundException(`Last-mile request ${id} not found`);
|
||||||
|
return record;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rule-based price estimate for the approval dialog: estimated km (yard GPS →
|
||||||
|
* delivery point, straight-line) × the LIVE last-mile rate rules against the
|
||||||
|
* containers the customer confirmed (or the booking's bulk tonnage). All
|
||||||
|
* nulls when km or rate coverage is missing — the dialog then behaves as
|
||||||
|
* before (manually typed advance).
|
||||||
|
*/
|
||||||
|
async priceEstimate(id: string): Promise<{
|
||||||
|
estimatedKm: number | null;
|
||||||
|
mode: LastMileCharge['mode'] | null;
|
||||||
|
currency: string | null;
|
||||||
|
total: number | null;
|
||||||
|
lines: Array<{ description: string; amount: number }>;
|
||||||
|
}> {
|
||||||
|
const request = await this.findById(id);
|
||||||
|
const estimatedKm = await estimateMileKm(this.dataSource, request.bookingId, 'LAST');
|
||||||
|
if (!estimatedKm) {
|
||||||
|
return { estimatedKm: null, mode: null, currency: null, total: null, lines: [] };
|
||||||
|
}
|
||||||
|
const shape = await lastMileShipmentShape(
|
||||||
|
this.dataSource,
|
||||||
|
request.bookingId,
|
||||||
|
request.requestedContainerNumbers ?? [],
|
||||||
|
);
|
||||||
|
const charge = computeLastMileCharge({
|
||||||
|
...shape,
|
||||||
|
km: estimatedKm,
|
||||||
|
liveRates: await this.ratesService.findLiveRatesDetailed(),
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
estimatedKm,
|
||||||
|
mode: charge?.mode ?? null,
|
||||||
|
currency: charge?.currency ?? null,
|
||||||
|
total: charge?.total ?? null,
|
||||||
|
lines: (charge?.lines ?? []).map(({ description, amount }) => ({ description, amount })),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Free (ACTIVE + unassigned) truck count — informational only for the approval screen. */
|
||||||
|
async freeTruckCount(): Promise<number> {
|
||||||
|
return this.dataSource.manager.count(Vehicle, {
|
||||||
|
where: { status: VehicleStatus.ACTIVE, availability: VehicleAvailability.FREE },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async submit(
|
||||||
|
id: string,
|
||||||
|
userId: string | null,
|
||||||
|
containerNumbers: string[],
|
||||||
|
deliveryDate: string,
|
||||||
|
): Promise<LastMileRequest> {
|
||||||
|
const request = await this.findById(id);
|
||||||
|
if (request.status !== LastMileRequestStatus.AwaitingConfirmation) {
|
||||||
|
throw new BadRequestException(`Request is already ${request.status.toLowerCase()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (userId) {
|
||||||
|
const companyId = await this.bookingsService.resolveCustomerCompanyId(userId);
|
||||||
|
if (companyId && request.booking?.companyId && companyId !== request.booking.companyId) {
|
||||||
|
throw new BadRequestException('This request does not belong to your company');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const bookingNumbers = await this.bookingContainerNumbers(request.bookingId);
|
||||||
|
const selected = containerNumbers.map((n) => n.trim().toUpperCase());
|
||||||
|
const unknown = selected.filter((n) => !bookingNumbers.includes(n));
|
||||||
|
if (unknown.length) {
|
||||||
|
throw new BadRequestException(`Container(s) not on this booking: ${unknown.join(', ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.requestsRepository.update(id, {
|
||||||
|
requestedContainerNumbers: selected,
|
||||||
|
requestedDeliveryDate: deliveryDate,
|
||||||
|
status: LastMileRequestStatus.Submitted,
|
||||||
|
submittedByUserId: userId,
|
||||||
|
submittedAt: new Date(),
|
||||||
|
} as Partial<LastMileRequest>);
|
||||||
|
|
||||||
|
void this.notifications.notify({
|
||||||
|
recipients: { permissionKeys: [FREIGHT_PERMS.lastMile.requestReview] },
|
||||||
|
audience: NotificationAudience.BACKOFFICE,
|
||||||
|
type: NotificationType.REQUEST_SUBMITTED,
|
||||||
|
title: 'Last-mile request ready for review',
|
||||||
|
body: `Booking ${request.booking?.reference ?? request.bookingId} confirmed ${selected.length} container(s) for EDR last-mile.`,
|
||||||
|
link: `/dashboard/operations/last-mile?tab=requests`,
|
||||||
|
data: { bookingId: request.bookingId, requestId: request.id },
|
||||||
|
priority: NotificationPriority.NORMAL,
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.findById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async approve(id: string, staffId: string | null, advanceAmount: number): Promise<LastMileRequest> {
|
||||||
|
const request = await this.findById(id);
|
||||||
|
if (request.status !== LastMileRequestStatus.Submitted) {
|
||||||
|
throw new BadRequestException(`Only a submitted request can be approved (current status: ${request.status})`);
|
||||||
|
}
|
||||||
|
const booking = request.booking ?? (await this.bookingsRepository.findById(request.bookingId));
|
||||||
|
if (!booking) throw new NotFoundException(`Booking ${request.bookingId} not found`);
|
||||||
|
|
||||||
|
// Idempotent per booking — reuses the record if one already exists.
|
||||||
|
const lastMile = await this.lastMileService.create({
|
||||||
|
bookingId: request.bookingId,
|
||||||
|
status: 'PAYMENT_PENDING',
|
||||||
|
advancedPayment: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
// No invoice yet: the advance is invoiced by LastMileContractService.sign()
|
||||||
|
// once the customer has signed the LM contract — doc first, then payment.
|
||||||
|
// Snapshot the rate estimate now so the contract shows the numbers the
|
||||||
|
// chief actually approved against, immune to later rate edits.
|
||||||
|
const estimate = await this.priceEstimate(id);
|
||||||
|
|
||||||
|
await this.requestsRepository.update(id, {
|
||||||
|
status: LastMileRequestStatus.Approved,
|
||||||
|
reviewedByStaffId: staffId,
|
||||||
|
reviewedAt: new Date(),
|
||||||
|
resultingLastMileId: lastMile.id,
|
||||||
|
approvedAdvanceAmount: advanceAmount,
|
||||||
|
contractSummary: { ...estimate, advanceAmount },
|
||||||
|
contractGeneratedAt: new Date(),
|
||||||
|
} as Partial<LastMileRequest>);
|
||||||
|
|
||||||
|
if (booking.companyId) {
|
||||||
|
void this.notifications.notify({
|
||||||
|
recipients: { companyId: booking.companyId },
|
||||||
|
audience: NotificationAudience.PORTAL,
|
||||||
|
type: NotificationType.CONTRACT_STATUS,
|
||||||
|
title: 'Last-mile contract ready — view and sign',
|
||||||
|
body: `Your last-mile request for booking ${booking.reference ?? booking.id} was approved. Review and sign the last-mile contract to receive your advance invoice.`,
|
||||||
|
link: `/bookings/${booking.id}/last-mile-contract?requestId=${id}`,
|
||||||
|
data: { bookingId: booking.id, requestId: id, lastMileId: lastMile.id },
|
||||||
|
priority: NotificationPriority.HIGH,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.findById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The advance invoice, deferred from approve() until the LM contract is signed. */
|
||||||
|
async generateAdvanceInvoice(request: LastMileRequest): Promise<void> {
|
||||||
|
const booking = request.booking ?? (await this.bookingsRepository.findById(request.bookingId));
|
||||||
|
if (!booking) throw new NotFoundException(`Booking ${request.bookingId} not found`);
|
||||||
|
const advanceAmount = request.approvedAdvanceAmount;
|
||||||
|
if (!advanceAmount || !request.resultingLastMileId) {
|
||||||
|
throw new BadRequestException('Request has no approved advance to invoice');
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.billing.generateInvoice({
|
||||||
|
// 'last_mile' (not the InvoiceSource.LastMile enum value "lastmile") to
|
||||||
|
// match the existing source string LastMileInvoiceService/LastMileService
|
||||||
|
// already query by (findBySourceIds/findPayable/attachInvoices).
|
||||||
|
source: 'last_mile' as Freight.InvoiceSource,
|
||||||
|
sourceId: request.resultingLastMileId,
|
||||||
|
type: 'LAST_MILE_ADVANCE',
|
||||||
|
companyId: booking.companyId,
|
||||||
|
companyProfileId: booking.companyProfileId || '',
|
||||||
|
currency: booking.paymentCurrency || 'ETB',
|
||||||
|
lines: [
|
||||||
|
{
|
||||||
|
chargeType: 'LAST_MILE_ADVANCE',
|
||||||
|
description: 'Last-mile delivery advance',
|
||||||
|
amount: advanceAmount,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
totalAmount: advanceAmount,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (booking.companyId) {
|
||||||
|
void this.notifications.notify({
|
||||||
|
recipients: { companyId: booking.companyId },
|
||||||
|
audience: NotificationAudience.PORTAL,
|
||||||
|
type: NotificationType.INVOICE_ISSUED,
|
||||||
|
title: 'Last-mile contract signed — payment due',
|
||||||
|
body: `Thank you for signing the last-mile contract for booking ${booking.reference ?? booking.id}. Pay the advance invoice to proceed.`,
|
||||||
|
link: '/billing/invoices',
|
||||||
|
data: { bookingId: booking.id, requestId: request.id, lastMileId: request.resultingLastMileId },
|
||||||
|
priority: NotificationPriority.HIGH,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async reject(id: string, staffId: string | null, reason: string): Promise<LastMileRequest> {
|
||||||
|
const request = await this.findById(id);
|
||||||
|
if (request.status !== LastMileRequestStatus.Submitted) {
|
||||||
|
throw new BadRequestException(`Only a submitted request can be rejected (current status: ${request.status})`);
|
||||||
|
}
|
||||||
|
const booking = request.booking ?? (await this.bookingsRepository.findById(request.bookingId));
|
||||||
|
|
||||||
|
await this.requestsRepository.update(id, {
|
||||||
|
status: LastMileRequestStatus.Rejected,
|
||||||
|
reviewedByStaffId: staffId,
|
||||||
|
reviewedAt: new Date(),
|
||||||
|
rejectionReason: reason,
|
||||||
|
} as Partial<LastMileRequest>);
|
||||||
|
|
||||||
|
if (booking?.companyId) {
|
||||||
|
void this.notifications.notify({
|
||||||
|
recipients: { companyId: booking.companyId },
|
||||||
|
audience: NotificationAudience.PORTAL,
|
||||||
|
type: NotificationType.BOOKING_STATUS,
|
||||||
|
title: 'Last-mile request rejected',
|
||||||
|
body: `Your last-mile request for booking ${booking.reference ?? booking.id} was rejected: ${reason}`,
|
||||||
|
link: `/bookings/${booking.id}`,
|
||||||
|
data: { bookingId: booking.id, requestId: id },
|
||||||
|
priority: NotificationPriority.HIGH,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.findById(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,13 +1,16 @@
|
|||||||
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
|
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
|
||||||
import { OnEvent } from '@nestjs/event-emitter';
|
import { OnEvent } from '@nestjs/event-emitter';
|
||||||
|
import { DataSource } from 'typeorm';
|
||||||
import { Freight } from '@edr/types';
|
import { Freight } from '@edr/types';
|
||||||
|
|
||||||
|
import { ruleBasedLastMileCharge } from '../../common/last-mile-charge.util';
|
||||||
import {
|
import {
|
||||||
BillingService,
|
BillingService,
|
||||||
GenerateInvoiceInput,
|
GenerateInvoiceInput,
|
||||||
InvoiceEventPayload,
|
InvoiceEventPayload,
|
||||||
} from '../billing/billing.service';
|
} from '../billing/billing.service';
|
||||||
import { Invoice } from '../billing/entities/invoice.entity';
|
import { Invoice } from '../billing/entities/invoice.entity';
|
||||||
|
import { RatesService } from '../rule-engine/services/rates.service';
|
||||||
import { LastMileRepository } from './last-mile.repository';
|
import { LastMileRepository } from './last-mile.repository';
|
||||||
import { LastMile } from './entities/last-mile.entity';
|
import { LastMile } from './entities/last-mile.entity';
|
||||||
|
|
||||||
@@ -26,6 +29,8 @@ export class LastMileInvoiceService {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly billing: BillingService,
|
private readonly billing: BillingService,
|
||||||
private readonly lastMileRepo: LastMileRepository,
|
private readonly lastMileRepo: LastMileRepository,
|
||||||
|
private readonly ratesService: RatesService,
|
||||||
|
private readonly dataSource: DataSource,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -54,6 +59,41 @@ export class LastMileInvoiceService {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Rule-based pricing first (bulk per-ton-km / container distance bands
|
||||||
|
// against the exact km): when a LIVE last-mile rate covers the job, it —
|
||||||
|
// not the per-vehicle price/km — is the delivery fee, with its own
|
||||||
|
// currency and per-size breakdown. Same resolver setDistances used to
|
||||||
|
// write remainingPayment, recomputed here so a rate change between the
|
||||||
|
// two moments settles on the invoice's side.
|
||||||
|
const exactKm = Number(record.exactKm) || 0;
|
||||||
|
const rule =
|
||||||
|
exactKm > 0
|
||||||
|
? await ruleBasedLastMileCharge(
|
||||||
|
this.dataSource,
|
||||||
|
await this.ratesService.findLiveRatesDetailed(),
|
||||||
|
record.id,
|
||||||
|
exactKm,
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
if (rule && rule.total > 0) {
|
||||||
|
return this.billing.generateInvoice({
|
||||||
|
source: 'last_mile' as Freight.InvoiceSource,
|
||||||
|
sourceId: record.id,
|
||||||
|
type: 'DELIVERY_FEE',
|
||||||
|
companyId: lm.booking!.companyId,
|
||||||
|
companyProfileId: lm.booking!.companyProfileId || '',
|
||||||
|
currency: rule.currency,
|
||||||
|
lines: rule.lines.map((line) => ({
|
||||||
|
chargeType: 'DELIVERY',
|
||||||
|
description: line.description,
|
||||||
|
quantity: line.quantity,
|
||||||
|
unitRate: line.unitRate,
|
||||||
|
amount: line.amount,
|
||||||
|
})),
|
||||||
|
totalAmount: rule.total,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// numeric columns come back as strings — coerce before billing.
|
// numeric columns come back as strings — coerce before billing.
|
||||||
const totalAmount = Number(record.remainingPayment) || 0;
|
const totalAmount = Number(record.remainingPayment) || 0;
|
||||||
if (!Number.isFinite(totalAmount) || totalAmount <= 0) {
|
if (!Number.isFinite(totalAmount) || totalAmount <= 0) {
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ function makeService(opts: { booking?: BookingRow; hasCustomerTruck?: boolean })
|
|||||||
{ query } as unknown as DataSource,
|
{ query } as unknown as DataSource,
|
||||||
{ record: jest.fn() } as never, // history
|
{ record: jest.fn() } as never, // history
|
||||||
{} as never, // billing
|
{} as never, // billing
|
||||||
|
{ findLiveRatesDetailed: jest.fn().mockResolvedValue([]) } as never, // ratesService
|
||||||
{} as never, // filesService
|
{} as never, // filesService
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ import {
|
|||||||
usesEdrMileService,
|
usesEdrMileService,
|
||||||
} from '../../common/mile-haulage.util';
|
} from '../../common/mile-haulage.util';
|
||||||
import { attachMileFinancials } from '../../common/mile-financials.util';
|
import { attachMileFinancials } from '../../common/mile-financials.util';
|
||||||
|
import { ruleBasedLastMileCharge } from '../../common/last-mile-charge.util';
|
||||||
|
import { estimateMileKm } from '../../common/mile-distance.util';
|
||||||
|
import { RatesService } from '../rule-engine/services/rates.service';
|
||||||
import {
|
import {
|
||||||
assertBulkTonnageRemains,
|
assertBulkTonnageRemains,
|
||||||
assertTruckCountWithinContainers,
|
assertTruckCountWithinContainers,
|
||||||
@@ -69,6 +72,7 @@ export class LastMileService {
|
|||||||
private readonly dataSource: DataSource,
|
private readonly dataSource: DataSource,
|
||||||
private readonly history: FleetHistoryService,
|
private readonly history: FleetHistoryService,
|
||||||
private readonly billing: BillingService,
|
private readonly billing: BillingService,
|
||||||
|
private readonly ratesService: RatesService,
|
||||||
private readonly filesService: FilesService,
|
private readonly filesService: FilesService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@@ -426,7 +430,7 @@ export class LastMileService {
|
|||||||
status: dto.status ?? 'READY_TO_TRANSIT',
|
status: dto.status ?? 'READY_TO_TRANSIT',
|
||||||
advancedPayment: dto.advancedPayment ?? 0,
|
advancedPayment: dto.advancedPayment ?? 0,
|
||||||
remainingPayment: dto.remainingPayment ?? 0,
|
remainingPayment: dto.remainingPayment ?? 0,
|
||||||
estimatedKm: dto.estimatedKm ?? null,
|
estimatedKm: dto.estimatedKm ?? (await estimateMileKm(this.dataSource, dto.bookingId, 'LAST')),
|
||||||
exactKm: dto.exactKm ?? null,
|
exactKm: dto.exactKm ?? null,
|
||||||
vehicleId: dto.vehicleId ?? null,
|
vehicleId: dto.vehicleId ?? null,
|
||||||
paid: (dto as any).paid ?? false,
|
paid: (dto as any).paid ?? false,
|
||||||
@@ -456,8 +460,21 @@ export class LastMileService {
|
|||||||
@OnEvent("last_mile.invoice.paid")
|
@OnEvent("last_mile.invoice.paid")
|
||||||
async onBookingInvoicePaid(payload: InvoiceEventPayload): Promise<void> {
|
async onBookingInvoicePaid(payload: InvoiceEventPayload): Promise<void> {
|
||||||
try {
|
try {
|
||||||
// Invoice paid → the delivery is complete. Route through update() so it
|
if (payload.type === 'LAST_MILE_ADVANCE') {
|
||||||
// also frees the trucks + records history (same as "Mark Delivered").
|
// Advance paid → the leg becomes dispatchable, not delivered.
|
||||||
|
await this.update(payload.sourceId, {
|
||||||
|
status: 'READY_TO_TRANSIT',
|
||||||
|
advancedPayment: payload.totalAmount,
|
||||||
|
} as unknown as UpdateLastMileDto);
|
||||||
|
this.logger.log(
|
||||||
|
`Last-mile ${payload.sourceId} READY_TO_TRANSIT on advance invoice ${payload.invoiceId} payment`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (payload.type !== 'DELIVERY_FEE') return;
|
||||||
|
// Delivery-fee invoice paid → the delivery is complete. Route through
|
||||||
|
// update() so it also frees the trucks + records history (same as
|
||||||
|
// "Mark Delivered").
|
||||||
await this.update(payload.sourceId, { status: 'DELIVERED', paid: true } as unknown as UpdateLastMileDto);
|
await this.update(payload.sourceId, { status: 'DELIVERED', paid: true } as unknown as UpdateLastMileDto);
|
||||||
this.logger.log(`Last-mile ${payload.sourceId} marked DELIVERED on invoice ${payload.invoiceId} payment`);
|
this.logger.log(`Last-mile ${payload.sourceId} marked DELIVERED on invoice ${payload.invoiceId} payment`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -985,9 +1002,18 @@ export class LastMileService {
|
|||||||
(s, a) => s + (Number(a.distanceKm) || 0) * (Number(a.vehicle?.pricePerKm) || 0),
|
(s, a) => s + (Number(a.distanceKm) || 0) * (Number(a.vehicle?.pricePerKm) || 0),
|
||||||
0,
|
0,
|
||||||
);
|
);
|
||||||
|
// Prefer the rule-based last-mile rate (bulk per-ton-km / container
|
||||||
|
// distance bands) over the per-vehicle price; the truck math stays as the
|
||||||
|
// fallback when no LIVE rule covers this job.
|
||||||
|
const rule = await ruleBasedLastMileCharge(
|
||||||
|
this.dataSource,
|
||||||
|
await this.ratesService.findLiveRatesDetailed(),
|
||||||
|
id,
|
||||||
|
total,
|
||||||
|
);
|
||||||
await this.lastMileRepository.update(id, {
|
await this.lastMileRepository.update(id, {
|
||||||
exactKm: total,
|
exactKm: total,
|
||||||
remainingPayment: amount,
|
remainingPayment: rule?.total ?? amount,
|
||||||
} as any);
|
} as any);
|
||||||
return this.findById(id);
|
return this.findById(id);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import {
|
import {
|
||||||
IsEnum,
|
IsEnum,
|
||||||
IsIn,
|
IsIn,
|
||||||
IsInt,
|
|
||||||
IsISO8601,
|
IsISO8601,
|
||||||
|
IsNumber,
|
||||||
IsOptional,
|
IsOptional,
|
||||||
IsPositive,
|
IsPositive,
|
||||||
IsString,
|
IsString,
|
||||||
@@ -37,7 +37,9 @@ export class PaymentEventDto {
|
|||||||
@ApiProperty() @IsString() referenceId!: string;
|
@ApiProperty() @IsString() referenceId!: string;
|
||||||
@ApiProperty() @IsString() merchantOrderId!: string;
|
@ApiProperty() @IsString() merchantOrderId!: string;
|
||||||
@ApiProperty({ enum: ProviderMethod }) @IsEnum(ProviderMethod) provider!: string;
|
@ApiProperty({ enum: ProviderMethod }) @IsEnum(ProviderMethod) provider!: string;
|
||||||
@ApiProperty() @IsInt() @IsPositive() amountMinor!: number;
|
// Major units, fractional (payment-api stores it as double precision) — an
|
||||||
|
// invoice of 12345.67 must not be rejected by an integer-only validator.
|
||||||
|
@ApiProperty() @IsNumber() @IsPositive() amountMinor!: number;
|
||||||
@ApiProperty() @IsString() currency!: string;
|
@ApiProperty() @IsString() currency!: string;
|
||||||
|
|
||||||
@ApiPropertyOptional() @IsOptional() @IsString() providerTxnId?: string;
|
@ApiPropertyOptional() @IsOptional() @IsString() providerTxnId?: string;
|
||||||
|
|||||||
@@ -8,7 +8,8 @@ import {
|
|||||||
} from '../entities/rate.entity';
|
} from '../entities/rate.entity';
|
||||||
|
|
||||||
const TRADE_DIRECTIONS = ['IMPORT', 'EXPORT', 'BOTH'] as const;
|
const TRADE_DIRECTIONS = ['IMPORT', 'EXPORT', 'BOTH'] as const;
|
||||||
const CURRENCIES = ['USD'] as const;
|
// ETB is accepted only for last-mile rates; the service forces USD elsewhere.
|
||||||
|
const CURRENCIES = ['USD', 'ETB'] as const;
|
||||||
export const INTERCITY_KINDS = ['CONTAINER', 'BULK'] as const;
|
export const INTERCITY_KINDS = ['CONTAINER', 'BULK'] as const;
|
||||||
export const CARGO_KINDS = ['CONTAINER', 'BULK'] as const;
|
export const CARGO_KINDS = ['CONTAINER', 'BULK'] as const;
|
||||||
|
|
||||||
@@ -92,6 +93,28 @@ export class CreateRateDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsIn([...RATE_UNITS])
|
@IsIn([...RATE_UNITS])
|
||||||
rateUnit?: string;
|
rateUnit?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description:
|
||||||
|
'Distance band start (km, inclusive). Container last-mile rates only (rateUnit = PER_KM).',
|
||||||
|
minimum: 0,
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
@Transform(({ value }) => (value === null || value === undefined || value === '' ? undefined : Number(value)))
|
||||||
|
minKm?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description:
|
||||||
|
'Distance band end (km, exclusive). Null/omitted = open-ended band. Container last-mile rates only.',
|
||||||
|
minimum: 0,
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
@Transform(({ value }) => (value === null || value === undefined || value === '' ? undefined : Number(value)))
|
||||||
|
maxKm?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class SubmitRateForApprovalDto {
|
export class SubmitRateForApprovalDto {
|
||||||
|
|||||||
@@ -93,8 +93,12 @@ function unitsForShape(input: {
|
|||||||
case 'INTERCITY':
|
case 'INTERCITY':
|
||||||
return ['PER_CONTAINER', 'PER_TON', 'PER_WAGON', 'PER_KM'];
|
return ['PER_CONTAINER', 'PER_TON', 'PER_WAGON', 'PER_KM'];
|
||||||
case 'FIRST_MILE':
|
case 'FIRST_MILE':
|
||||||
case 'LAST_MILE':
|
|
||||||
return ['PER_CONTAINER', 'PER_TON', 'PER_KM', 'FLAT'];
|
return ['PER_CONTAINER', 'PER_TON', 'PER_KM', 'FLAT'];
|
||||||
|
case 'LAST_MILE':
|
||||||
|
// PER_KM = container mode (banded by distance + container size),
|
||||||
|
// PER_TON_KM = bulk mode (tons × km × rate). Legacy units kept for
|
||||||
|
// existing rows.
|
||||||
|
return ['PER_KM', 'PER_TON_KM', 'PER_CONTAINER', 'PER_TON', 'FLAT'];
|
||||||
default:
|
default:
|
||||||
return ['FLAT'];
|
return ['FLAT'];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,6 +39,8 @@ export const RATE_UNITS = [
|
|||||||
'PER_ITEM',
|
'PER_ITEM',
|
||||||
'PER_CONTAINER',
|
'PER_CONTAINER',
|
||||||
'PER_KM',
|
'PER_KM',
|
||||||
|
// Last-mile bulk: price = tons × km × rateValue.
|
||||||
|
'PER_TON_KM',
|
||||||
'PER_INVOICE',
|
'PER_INVOICE',
|
||||||
'FLAT',
|
'FLAT',
|
||||||
] as const;
|
] as const;
|
||||||
@@ -156,6 +158,17 @@ export class Rate extends BaseEntity {
|
|||||||
@Column({ name: 'rate_unit', type: 'varchar', length: 30 })
|
@Column({ name: 'rate_unit', type: 'varchar', length: 30 })
|
||||||
rateUnit!: RateUnit;
|
rateUnit!: RateUnit;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Distance band for container last-mile rates (rateUnit = PER_KM, scoped by
|
||||||
|
* containerTypeId): the rate applies when minKm <= km < maxKm (maxKm NULL =
|
||||||
|
* open-ended). NULL on every other rate shape.
|
||||||
|
*/
|
||||||
|
@Column({ name: 'min_km', type: 'numeric', precision: 10, scale: 2, nullable: true })
|
||||||
|
minKm?: number | null;
|
||||||
|
|
||||||
|
@Column({ name: 'max_km', type: 'numeric', precision: 10, scale: 2, nullable: true })
|
||||||
|
maxKm?: number | null;
|
||||||
|
|
||||||
@Column({ name: 'status', type: 'varchar', length: 20, default: 'DRAFT' })
|
@Column({ name: 'status', type: 'varchar', length: 20, default: 'DRAFT' })
|
||||||
status!: RateStatus;
|
status!: RateStatus;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { BaseEntity } from '@edr/api-common';
|
||||||
|
import { Column, Entity, Index, JoinColumn, OneToOne } from 'typeorm';
|
||||||
|
|
||||||
|
import { Yard } from './yard.entity';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GPS position of a yard (decimal degrees, WGS84). One record per yard;
|
||||||
|
* today only the five facility yards (Sebeta, GMP/Indode, Mojo, Adama,
|
||||||
|
* Dire Dawa) are seeded.
|
||||||
|
*/
|
||||||
|
@Entity({ schema: 'freight', name: 'yard_locations' })
|
||||||
|
@Index(['yardId'], { unique: true })
|
||||||
|
export class YardLocation extends BaseEntity {
|
||||||
|
@Column({ name: 'yard_id', type: 'uuid' })
|
||||||
|
yardId!: string;
|
||||||
|
|
||||||
|
@OneToOne(() => Yard, { nullable: false, onDelete: 'CASCADE' })
|
||||||
|
@JoinColumn({ name: 'yard_id' })
|
||||||
|
yard?: Yard;
|
||||||
|
|
||||||
|
@Column({ name: 'latitude', type: 'double precision' })
|
||||||
|
latitude!: number;
|
||||||
|
|
||||||
|
@Column({ name: 'longitude', type: 'double precision' })
|
||||||
|
longitude!: number;
|
||||||
|
}
|
||||||
@@ -21,6 +21,8 @@ export interface IRatesRepository {
|
|||||||
tradeDirection?: string | null;
|
tradeDirection?: string | null;
|
||||||
originYardId?: string | null;
|
originYardId?: string | null;
|
||||||
destinationYardId?: string | null;
|
destinationYardId?: string | null;
|
||||||
|
/** Band start for container last-mile rates; omitted/null elsewhere. */
|
||||||
|
minKm?: number | null;
|
||||||
}): Promise<Rate | null>;
|
}): Promise<Rate | null>;
|
||||||
findAll(options?: FindManyOptions<Rate>): Promise<Rate[]>;
|
findAll(options?: FindManyOptions<Rate>): Promise<Rate[]>;
|
||||||
findAndCount(options?: FindManyOptions<Rate>): Promise<[Rate[], number]>;
|
findAndCount(options?: FindManyOptions<Rate>): Promise<[Rate[], number]>;
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ export class RatesRepository implements IRatesRepository {
|
|||||||
tradeDirection?: string | null;
|
tradeDirection?: string | null;
|
||||||
originYardId?: string | null;
|
originYardId?: string | null;
|
||||||
destinationYardId?: string | null;
|
destinationYardId?: string | null;
|
||||||
|
minKm?: number | null;
|
||||||
}): Promise<Rate | null> {
|
}): Promise<Rate | null> {
|
||||||
const qb = this.repo
|
const qb = this.repo
|
||||||
.createQueryBuilder('rate')
|
.createQueryBuilder('rate')
|
||||||
@@ -111,6 +112,13 @@ export class RatesRepository implements IRatesRepository {
|
|||||||
} else {
|
} else {
|
||||||
qb.andWhere('rate.destination_yard_id IS NULL');
|
qb.andWhere('rate.destination_yard_id IS NULL');
|
||||||
}
|
}
|
||||||
|
// Band start distinguishes sibling last-mile bands, mirroring the
|
||||||
|
// COALESCE(min_km, -1) column of UQ_rates_pattern.
|
||||||
|
if (pattern.minKm !== null && pattern.minKm !== undefined) {
|
||||||
|
qb.andWhere('rate.min_km = :minKm', { minKm: pattern.minKm });
|
||||||
|
} else {
|
||||||
|
qb.andWhere('rate.min_km IS NULL');
|
||||||
|
}
|
||||||
|
|
||||||
return qb.getOne();
|
return qb.getOne();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,6 +43,12 @@ export class YardsRepository implements IYardsRepository {
|
|||||||
findPaged(query: ListYardsQueryDto): Promise<PaginatedResponse<Yard>> {
|
findPaged(query: ListYardsQueryDto): Promise<PaginatedResponse<Yard>> {
|
||||||
const qb = this.repo
|
const qb = this.repo
|
||||||
.createQueryBuilder('yard')
|
.createQueryBuilder('yard')
|
||||||
|
// createQueryBuilder does NOT auto-apply the soft-delete filter that
|
||||||
|
// repo.find()/findOne() get for free — without this, a renamed/replaced
|
||||||
|
// yard (e.g. an old "DMP" superseded by a new one) still shows up
|
||||||
|
// alongside the live one in every picker built off this endpoint, and a
|
||||||
|
// route picked against the dead yard id never matches any LIVE rate.
|
||||||
|
.where('yard.deleted_at IS NULL')
|
||||||
.orderBy(`yard.${query.sortBy ?? 'displayOrder'}`, query.sortOrder ?? 'ASC')
|
.orderBy(`yard.${query.sortBy ?? 'displayOrder'}`, query.sortOrder ?? 'ASC')
|
||||||
.addOrderBy('yard.label', 'ASC');
|
.addOrderBy('yard.label', 'ASC');
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import { WeightLimitRule } from './entities/weight-limit-rule.entity';
|
|||||||
import { Yard } from './entities/yard.entity';
|
import { Yard } from './entities/yard.entity';
|
||||||
import { YardDistance } from './entities/yard-distance.entity';
|
import { YardDistance } from './entities/yard-distance.entity';
|
||||||
import { YardFacility } from './entities/yard-facility.entity';
|
import { YardFacility } from './entities/yard-facility.entity';
|
||||||
|
import { YardLocation } from './entities/yard-location.entity';
|
||||||
|
|
||||||
import { APPROVAL_RULES_REPOSITORY } from './interfaces/approval-rules.repository.interface';
|
import { APPROVAL_RULES_REPOSITORY } from './interfaces/approval-rules.repository.interface';
|
||||||
import { CARGO_TYPES_REPOSITORY } from './interfaces/cargo-types.repository.interface';
|
import { CARGO_TYPES_REPOSITORY } from './interfaces/cargo-types.repository.interface';
|
||||||
@@ -88,6 +89,7 @@ import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot.
|
|||||||
Yard,
|
Yard,
|
||||||
YardDistance,
|
YardDistance,
|
||||||
YardFacility,
|
YardFacility,
|
||||||
|
YardLocation,
|
||||||
ShippingLine,
|
ShippingLine,
|
||||||
Rate,
|
Rate,
|
||||||
ApprovalRule,
|
ApprovalRule,
|
||||||
|
|||||||
@@ -37,6 +37,10 @@ const DIFFABLE_FIELDS = [
|
|||||||
// diffed to nothing and the submit was refused as "nothing changed".
|
// diffed to nothing and the submit was refused as "nothing changed".
|
||||||
'originYardId',
|
'originYardId',
|
||||||
'destinationYardId',
|
'destinationYardId',
|
||||||
|
// Container last-mile distance bands. Missing here, a band-range edit on a
|
||||||
|
// LIVE last-mile rate would diff to "nothing changed".
|
||||||
|
'minKm',
|
||||||
|
'maxKm',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { PaginatedResponse, YardCountry } from '@edr/types';
|
import { PaginatedResponse, YardCountry } from '@edr/types';
|
||||||
|
import { IsNull, Not } from 'typeorm';
|
||||||
import { CreateRateDto } from '../dto/create-rate.dto';
|
import { CreateRateDto } from '../dto/create-rate.dto';
|
||||||
import { ListRatesQueryDto } from '../dto/list-rule-engine-query.dto';
|
import { ListRatesQueryDto } from '../dto/list-rule-engine-query.dto';
|
||||||
import { UpdateRateDto } from '../dto/update-rate.dto';
|
import { UpdateRateDto } from '../dto/update-rate.dto';
|
||||||
@@ -340,6 +341,113 @@ export class RatesService {
|
|||||||
return appliesTo === 'INTERCITY' ? intercityKind === 'BULK' : appliesTo === 'BULK';
|
return appliesTo === 'INTERCITY' ? intercityKind === 'BULK' : appliesTo === 'BULK';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate and normalise the last-mile band fields for a rate shape.
|
||||||
|
*
|
||||||
|
* Last-mile rates come in two calculation modes: bulk (PER_TON_KM — one row
|
||||||
|
* per distance band, price = tons × km × rate) and container (PER_KM — one
|
||||||
|
* row per container type per distance band, price = km × rate × quantity).
|
||||||
|
* A bandless bulk row (NULL minKm) is the legacy pre-band shape and still
|
||||||
|
* prices every distance. Every other rate shape has its band fields cleared,
|
||||||
|
* mirroring how yard scope is cleared for non-route rates.
|
||||||
|
*/
|
||||||
|
private resolveLastMileBand(input: {
|
||||||
|
appliesTo: Rate['appliesTo'];
|
||||||
|
rateUnit: Rate['rateUnit'];
|
||||||
|
containerTypeId: string | null;
|
||||||
|
minKm?: number | null;
|
||||||
|
maxKm?: number | null;
|
||||||
|
}): { minKm: number | null; maxKm: number | null } {
|
||||||
|
const { appliesTo, rateUnit, containerTypeId } = input;
|
||||||
|
if (appliesTo !== 'LAST_MILE') return { minKm: null, maxKm: null };
|
||||||
|
|
||||||
|
if (rateUnit === 'PER_TON_KM') {
|
||||||
|
if (containerTypeId) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'A bulk last-mile rate (per ton per km) cannot be scoped to a container type.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const minKm = input.minKm ?? null;
|
||||||
|
const maxKm = input.maxKm ?? null;
|
||||||
|
if (minKm === null) {
|
||||||
|
if (maxKm !== null) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'"To km" needs a "From km" — set the band start (0 for the first tier).',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Legacy bandless bulk rate — prices every distance.
|
||||||
|
return { minKm: null, maxKm: null };
|
||||||
|
}
|
||||||
|
if (maxKm !== null && maxKm <= minKm) {
|
||||||
|
throw new BadRequestException('"To km" must be greater than "From km".');
|
||||||
|
}
|
||||||
|
return { minKm, maxKm };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rateUnit === 'PER_KM') {
|
||||||
|
if (!containerTypeId) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'A container last-mile rate must name the container type it covers (20ft and 40ft price differently).',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const minKm = input.minKm ?? null;
|
||||||
|
const maxKm = input.maxKm ?? null;
|
||||||
|
if (minKm === null) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'A container last-mile rate needs a distance band — set "From km" (0 for the first band).',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (maxKm !== null && maxKm <= minKm) {
|
||||||
|
throw new BadRequestException('"To km" must be greater than "From km".');
|
||||||
|
}
|
||||||
|
return { minKm, maxKm };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Legacy last-mile shapes (FLAT / PER_CONTAINER / PER_TON) carry no band.
|
||||||
|
return { minKm: null, maxKm: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reject a last-mile band that overlaps an existing band for the same scope —
|
||||||
|
* container bands collide per container type (PER_KM), bulk bands collide
|
||||||
|
* with each other (PER_TON_KM, no container scope). Bands are half-open
|
||||||
|
* [minKm, maxKm) with NULL maxKm = open-ended, so 0–30 and 30–∞ tile
|
||||||
|
* cleanly. Checked across every non-superseded row (DRAFT included) — two
|
||||||
|
* drafts with colliding bands would only defer the conflict to approval.
|
||||||
|
*/
|
||||||
|
private async assertNoBandOverlap(input: {
|
||||||
|
rateUnit: 'PER_KM' | 'PER_TON_KM';
|
||||||
|
containerTypeId: string | null;
|
||||||
|
minKm: number;
|
||||||
|
maxKm: number | null;
|
||||||
|
ignoreId?: string;
|
||||||
|
}): Promise<void> {
|
||||||
|
const siblings = await this.repository.findAll({
|
||||||
|
where: {
|
||||||
|
rateType: 'LAST_MILE',
|
||||||
|
rateUnit: input.rateUnit,
|
||||||
|
containerTypeId: input.containerTypeId ?? IsNull(),
|
||||||
|
status: Not('SUPERSEDED'),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const newMax = input.maxKm ?? Number.POSITIVE_INFINITY;
|
||||||
|
for (const sibling of siblings) {
|
||||||
|
if (sibling.id === input.ignoreId) continue;
|
||||||
|
if (sibling.minKm === null || sibling.minKm === undefined) continue; // legacy row, no band
|
||||||
|
const sibMin = Number(sibling.minKm);
|
||||||
|
const sibMax =
|
||||||
|
sibling.maxKm === null || sibling.maxKm === undefined
|
||||||
|
? Number.POSITIVE_INFINITY
|
||||||
|
: Number(sibling.maxKm);
|
||||||
|
if (input.minKm < sibMax && sibMin < newMax) {
|
||||||
|
const sibLabel = `${sibMin}–${sibMax === Number.POSITIVE_INFINITY ? 'open' : sibMax} km`;
|
||||||
|
throw new ConflictException(
|
||||||
|
`This distance band overlaps the existing ${sibLabel} band for ${input.rateUnit === 'PER_TON_KM' ? 'bulk last-mile' : 'this container type'}. Adjust the ranges so each distance falls in exactly one band.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reject a second rate with the same identity pattern (rateType + scope). With
|
* Reject a second rate with the same identity pattern (rateType + scope). With
|
||||||
* effective-date windows gone, two LIVE/DRAFT rates for the same pattern would
|
* effective-date windows gone, two LIVE/DRAFT rates for the same pattern would
|
||||||
@@ -360,6 +468,8 @@ export class RatesService {
|
|||||||
tradeDirection: string | null;
|
tradeDirection: string | null;
|
||||||
originYardId: string | null;
|
originYardId: string | null;
|
||||||
destinationYardId: string | null;
|
destinationYardId: string | null;
|
||||||
|
/** Band start — part of the identity for container last-mile bands only. */
|
||||||
|
minKm?: number | null;
|
||||||
ignoreId?: string;
|
ignoreId?: string;
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
const existing = await this.repository.findByPattern(pattern);
|
const existing = await this.repository.findByPattern(pattern);
|
||||||
@@ -438,6 +548,21 @@ export class RatesService {
|
|||||||
cargoTypeId,
|
cargoTypeId,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const { minKm, maxKm } = this.resolveLastMileBand({
|
||||||
|
appliesTo,
|
||||||
|
rateUnit,
|
||||||
|
containerTypeId,
|
||||||
|
minKm: dto.minKm,
|
||||||
|
maxKm: dto.maxKm,
|
||||||
|
});
|
||||||
|
if (
|
||||||
|
appliesTo === 'LAST_MILE' &&
|
||||||
|
(rateUnit === 'PER_KM' || rateUnit === 'PER_TON_KM') &&
|
||||||
|
minKm !== null
|
||||||
|
) {
|
||||||
|
await this.assertNoBandOverlap({ rateUnit, containerTypeId, minKm, maxKm });
|
||||||
|
}
|
||||||
|
|
||||||
await this.assertNoDuplicatePattern({
|
await this.assertNoDuplicatePattern({
|
||||||
rateType,
|
rateType,
|
||||||
...(this.resolvesSingleRate(appliesTo, trigger) ? {} : { rateUnit }),
|
...(this.resolvesSingleRate(appliesTo, trigger) ? {} : { rateUnit }),
|
||||||
@@ -446,6 +571,7 @@ export class RatesService {
|
|||||||
tradeDirection,
|
tradeDirection,
|
||||||
originYardId,
|
originYardId,
|
||||||
destinationYardId,
|
destinationYardId,
|
||||||
|
minKm,
|
||||||
});
|
});
|
||||||
|
|
||||||
return this.repository.create({
|
return this.repository.create({
|
||||||
@@ -457,9 +583,13 @@ export class RatesService {
|
|||||||
tradeDirection,
|
tradeDirection,
|
||||||
originYardId,
|
originYardId,
|
||||||
destinationYardId,
|
destinationYardId,
|
||||||
currency: dto.currency ?? 'USD',
|
// Last-mile is the one shape sold in birr (or USD); everything else is
|
||||||
|
// USD by contract.
|
||||||
|
currency: appliesTo === 'LAST_MILE' ? (dto.currency ?? 'ETB') : 'USD',
|
||||||
rateValue: dto.rateValue,
|
rateValue: dto.rateValue,
|
||||||
rateUnit,
|
rateUnit,
|
||||||
|
minKm,
|
||||||
|
maxKm,
|
||||||
status: 'DRAFT',
|
status: 'DRAFT',
|
||||||
proposedByStaffId,
|
proposedByStaffId,
|
||||||
});
|
});
|
||||||
@@ -622,6 +752,29 @@ export class RatesService {
|
|||||||
);
|
);
|
||||||
updates.rateUnit = rateUnit;
|
updates.rateUnit = rateUnit;
|
||||||
|
|
||||||
|
const { minKm, maxKm } = this.resolveLastMileBand({
|
||||||
|
appliesTo,
|
||||||
|
rateUnit,
|
||||||
|
containerTypeId: updates.containerTypeId,
|
||||||
|
minKm: dto.minKm !== undefined ? dto.minKm : existing.minKm,
|
||||||
|
maxKm: dto.maxKm !== undefined ? dto.maxKm : existing.maxKm,
|
||||||
|
});
|
||||||
|
updates.minKm = minKm;
|
||||||
|
updates.maxKm = maxKm;
|
||||||
|
if (
|
||||||
|
appliesTo === 'LAST_MILE' &&
|
||||||
|
(rateUnit === 'PER_KM' || rateUnit === 'PER_TON_KM') &&
|
||||||
|
minKm !== null
|
||||||
|
) {
|
||||||
|
await this.assertNoBandOverlap({
|
||||||
|
rateUnit,
|
||||||
|
containerTypeId: updates.containerTypeId ?? null,
|
||||||
|
minKm,
|
||||||
|
maxKm,
|
||||||
|
ignoreId: id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Guard the pattern uniqueness for the new identity, ignoring this row.
|
// Guard the pattern uniqueness for the new identity, ignoring this row.
|
||||||
await this.assertNoDuplicatePattern({
|
await this.assertNoDuplicatePattern({
|
||||||
rateType,
|
rateType,
|
||||||
@@ -631,10 +784,14 @@ export class RatesService {
|
|||||||
tradeDirection: updates.tradeDirection,
|
tradeDirection: updates.tradeDirection,
|
||||||
originYardId: updates.originYardId,
|
originYardId: updates.originYardId,
|
||||||
destinationYardId: updates.destinationYardId,
|
destinationYardId: updates.destinationYardId,
|
||||||
|
minKm,
|
||||||
ignoreId: id,
|
ignoreId: id,
|
||||||
});
|
});
|
||||||
|
|
||||||
updates.currency = dto.currency ?? existing.currency ?? 'USD';
|
updates.currency =
|
||||||
|
appliesTo === 'LAST_MILE'
|
||||||
|
? (dto.currency ?? existing.currency ?? 'ETB')
|
||||||
|
: 'USD';
|
||||||
if (dto.rateValue !== undefined) updates.rateValue = dto.rateValue;
|
if (dto.rateValue !== undefined) updates.rateValue = dto.rateValue;
|
||||||
return updates;
|
return updates;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,13 +35,24 @@ async function main() {
|
|||||||
// Demo seeders are intentionally not AppModule providers (they'd run on every
|
// Demo seeders are intentionally not AppModule providers (they'd run on every
|
||||||
// boot), so construct them against the app's DataSource instead of via DI.
|
// boot), so construct them against the app's DataSource instead of via DI.
|
||||||
const dataSource = app.get(DataSource);
|
const dataSource = app.get(DataSource);
|
||||||
await new PricingDataSeeder(dataSource).run();
|
|
||||||
await new IndodeFacilitySeeder(dataSource).run();
|
// Each bucket is independent: a seeder that has drifted from the current
|
||||||
await new Batch14TestDataSeeder(dataSource).run();
|
// schema shouldn't stop the rest of the demo data from landing.
|
||||||
await new Batch5TestDataSeeder(dataSource).run();
|
const step = async (name: string, run: () => Promise<void>) => {
|
||||||
await new Batch7TestDataSeeder(dataSource).run();
|
try {
|
||||||
await new Batch8TestDataSeeder(dataSource).run();
|
await run();
|
||||||
await new WarehouseDemoSeeder(dataSource).run();
|
} catch (error) {
|
||||||
|
console.warn(` ! ${name} skipped: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
await step('PricingDataSeeder', () => new PricingDataSeeder(dataSource).run());
|
||||||
|
await step('IndodeFacilitySeeder', () => new IndodeFacilitySeeder(dataSource).run());
|
||||||
|
await step('Batch14TestDataSeeder', () => new Batch14TestDataSeeder(dataSource).run());
|
||||||
|
await step('Batch5TestDataSeeder', () => new Batch5TestDataSeeder(dataSource).run());
|
||||||
|
await step('Batch7TestDataSeeder', () => new Batch7TestDataSeeder(dataSource).run());
|
||||||
|
await step('Batch8TestDataSeeder', () => new Batch8TestDataSeeder(dataSource).run());
|
||||||
|
await step('WarehouseDemoSeeder', () => new WarehouseDemoSeeder(dataSource).run());
|
||||||
|
|
||||||
console.log('Warehouse demo data seeded.');
|
console.log('Warehouse demo data seeded.');
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -306,4 +306,5 @@ export const EDR_FREIGHT_POSITIONS: FreightSeedPosition[] = [
|
|||||||
{ key: "operation", name: { en: "Operation" }, rank: 4, permissionKeys: [...POSITION_PERMISSION_PRESETS.operation] },
|
{ key: "operation", name: { en: "Operation" }, rank: 4, permissionKeys: [...POSITION_PERMISSION_PRESETS.operation] },
|
||||||
{ key: "operations_chief", name: { en: "Operations Chief" }, rank: 2, permissionKeys: [...POSITION_PERMISSION_PRESETS.operationsChief] },
|
{ key: "operations_chief", name: { en: "Operations Chief" }, rank: 2, permissionKeys: [...POSITION_PERMISSION_PRESETS.operationsChief] },
|
||||||
{ key: "dispatcher", name: { en: "Dispatcher" }, rank: 4, permissionKeys: [...POSITION_PERMISSION_PRESETS.dispatcher] },
|
{ key: "dispatcher", name: { en: "Dispatcher" }, rank: 4, permissionKeys: [...POSITION_PERMISSION_PRESETS.dispatcher] },
|
||||||
|
{ key: "truck_machinery_chief", name: { en: "Truck & Machinery Chief" }, rank: 2, permissionKeys: [...POSITION_PERMISSION_PRESETS.truckMachineryChief] },
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -227,6 +227,9 @@ export const MILE_PERMISSIONS: FreightPermissionSeed[] = [
|
|||||||
perm('d3b00001-0001-4000-8000-000000000006', 'edr_freight_app:last_mile:assign_vehicles', 'Assign last-mile vehicles'),
|
perm('d3b00001-0001-4000-8000-000000000006', 'edr_freight_app:last_mile:assign_vehicles', 'Assign last-mile vehicles'),
|
||||||
perm('d3b00001-0001-4000-8000-000000000007', 'edr_freight_app:last_mile:set_distances', 'Set last-mile distances'),
|
perm('d3b00001-0001-4000-8000-000000000007', 'edr_freight_app:last_mile:set_distances', 'Set last-mile distances'),
|
||||||
perm('d3b00001-0001-4000-8000-000000000008', 'edr_freight_app:last_mile:generate_invoice', 'Generate last-mile invoice'),
|
perm('d3b00001-0001-4000-8000-000000000008', 'edr_freight_app:last_mile:generate_invoice', 'Generate last-mile invoice'),
|
||||||
|
perm('d3b00001-0001-4000-8000-000000000009', 'edr_freight_app:last_mile:request_view', 'View last-mile confirmation requests'),
|
||||||
|
perm('d3b00001-0001-4000-8000-00000000000a', 'edr_freight_app:last_mile:request_review', 'Review last-mile confirmation requests (T&M dept)'),
|
||||||
|
perm('d3b00001-0001-4000-8000-00000000000b', 'edr_freight_app:last_mile:request_approve', 'Approve/reject last-mile confirmation requests'),
|
||||||
];
|
];
|
||||||
|
|
||||||
// F. Fleet — rail assets (splits the flat fleet:view/manage)
|
// F. Fleet — rail assets (splits the flat fleet:view/manage)
|
||||||
@@ -366,6 +369,7 @@ export const CONFIG_SETTINGS_PERMISSIONS: FreightPermissionSeed[] = [
|
|||||||
perm('b4a00001-0001-4000-8000-000000000002', 'edr_freight_app:settings:file_upload:manage', 'Manage file-upload settings'),
|
perm('b4a00001-0001-4000-8000-000000000002', 'edr_freight_app:settings:file_upload:manage', 'Manage file-upload settings'),
|
||||||
perm('b4b00001-0001-4000-8000-000000000001', 'edr_freight_app:settings:dropdown:view', 'View dropdown settings'),
|
perm('b4b00001-0001-4000-8000-000000000001', 'edr_freight_app:settings:dropdown:view', 'View dropdown settings'),
|
||||||
perm('b4b00001-0001-4000-8000-000000000002', 'edr_freight_app:settings:dropdown:manage', 'Manage dropdown settings'),
|
perm('b4b00001-0001-4000-8000-000000000002', 'edr_freight_app:settings:dropdown:manage', 'Manage dropdown settings'),
|
||||||
|
perm('b4c00001-0001-4000-8000-000000000001', 'edr_freight_app:audit:view', 'View audit logs'),
|
||||||
];
|
];
|
||||||
|
|
||||||
// M. Staff / IAM admin — NEW keys only. The employee_registration / role_assignment
|
// M. Staff / IAM admin — NEW keys only. The employee_registration / role_assignment
|
||||||
@@ -527,6 +531,11 @@ export const FREIGHT_PERMS = {
|
|||||||
assignVehicles: 'edr_freight_app:last_mile:assign_vehicles',
|
assignVehicles: 'edr_freight_app:last_mile:assign_vehicles',
|
||||||
setDistances: 'edr_freight_app:last_mile:set_distances',
|
setDistances: 'edr_freight_app:last_mile:set_distances',
|
||||||
generateInvoice: 'edr_freight_app:last_mile:generate_invoice',
|
generateInvoice: 'edr_freight_app:last_mile:generate_invoice',
|
||||||
|
// Pre-approval confirmation stage (Truck & Machinery department): view/review
|
||||||
|
// a submitted request, approve/reject it.
|
||||||
|
requestView: 'edr_freight_app:last_mile:request_view',
|
||||||
|
requestReview: 'edr_freight_app:last_mile:request_review',
|
||||||
|
requestApprove: 'edr_freight_app:last_mile:request_approve',
|
||||||
},
|
},
|
||||||
locomotives: {
|
locomotives: {
|
||||||
view: 'edr_freight_app:locomotives:view',
|
view: 'edr_freight_app:locomotives:view',
|
||||||
@@ -700,6 +709,9 @@ export const FREIGHT_PERMS = {
|
|||||||
manage: 'edr_freight_app:settings:dropdown:manage',
|
manage: 'edr_freight_app:settings:dropdown:manage',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
audit: {
|
||||||
|
view: 'edr_freight_app:audit:view',
|
||||||
|
},
|
||||||
staff: {
|
staff: {
|
||||||
roles: {
|
roles: {
|
||||||
view: 'edr_freight_app:staff:roles:view',
|
view: 'edr_freight_app:staff:roles:view',
|
||||||
@@ -1011,6 +1023,17 @@ export const POSITION_PERMISSION_PRESETS = {
|
|||||||
FREIGHT_PERMS.trainScheduling.view,
|
FREIGHT_PERMS.trainScheduling.view,
|
||||||
FREIGHT_PERMS.bookings.operations,
|
FREIGHT_PERMS.bookings.operations,
|
||||||
]),
|
]),
|
||||||
|
// Truck & Machinery chief: reviews and approves/rejects last-mile
|
||||||
|
// confirmation requests (the pre-approval gate ahead of vehicle assignment),
|
||||||
|
// plus enough fleet visibility to judge truck availability.
|
||||||
|
truckMachineryChief: dedupe([
|
||||||
|
FREIGHT_PERMS.lastMile.view,
|
||||||
|
FREIGHT_PERMS.lastMile.requestView,
|
||||||
|
FREIGHT_PERMS.lastMile.requestReview,
|
||||||
|
FREIGHT_PERMS.lastMile.requestApprove,
|
||||||
|
FREIGHT_PERMS.fleetDashboard.view,
|
||||||
|
FREIGHT_PERMS.vehicles.view,
|
||||||
|
]),
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
/** Derive the module bucket from the resource segment of a permission key. */
|
/** Derive the module bucket from the resource segment of a permission key. */
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ const STAFF_USERS = [
|
|||||||
{ email: 'operation@edr.local', username: 'operation', roleKey: 'edr_operations_officer', positionKey: 'operation' },
|
{ email: 'operation@edr.local', username: 'operation', roleKey: 'edr_operations_officer', positionKey: 'operation' },
|
||||||
{ email: 'gl-et@edr.local', username: 'gl_et', roleKey: 'edr_gl_ethiopia', positionKey: 'ethiopian_gl' },
|
{ email: 'gl-et@edr.local', username: 'gl_et', roleKey: 'edr_gl_ethiopia', positionKey: 'ethiopian_gl' },
|
||||||
{ email: 'gl-dj@edr.local', username: 'gl_dj', roleKey: 'edr_gl_djibouti', positionKey: 'djibouti_gl' },
|
{ email: 'gl-dj@edr.local', username: 'gl_dj', roleKey: 'edr_gl_djibouti', positionKey: 'djibouti_gl' },
|
||||||
|
{ email: 'tm-chief@edr.local', username: 'tm_chief', roleKey: 'edr_operations_officer', positionKey: 'truck_machinery_chief' },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
|
|||||||
@@ -4,6 +4,11 @@ import { DataSource } from 'typeorm';
|
|||||||
|
|
||||||
import { BookingContainer } from '../modules/bookings/entities/booking-container.entity';
|
import { BookingContainer } from '../modules/bookings/entities/booking-container.entity';
|
||||||
import { Booking } from '../modules/bookings/entities/booking.entity';
|
import { Booking } from '../modules/bookings/entities/booking.entity';
|
||||||
|
import {
|
||||||
|
CompanyProfile,
|
||||||
|
ProfileStatus,
|
||||||
|
ProfileType,
|
||||||
|
} from '../modules/companies/entities/company-profile.entity';
|
||||||
import { Company, CompanyStatus, CompanyType } from '../modules/companies/entities/company.entity';
|
import { Company, CompanyStatus, CompanyType } from '../modules/companies/entities/company.entity';
|
||||||
import { FirstMile } from '../modules/first-mile/entities/first-mile.entity';
|
import { FirstMile } from '../modules/first-mile/entities/first-mile.entity';
|
||||||
import { LastMile } from '../modules/last-mile/entities/last-mile.entity';
|
import { LastMile } from '../modules/last-mile/entities/last-mile.entity';
|
||||||
@@ -13,7 +18,8 @@ import { ServiceType } from '../modules/rule-engine/entities/service-type.entity
|
|||||||
import { Yard } from '../modules/rule-engine/entities/yard.entity';
|
import { Yard } from '../modules/rule-engine/entities/yard.entity';
|
||||||
|
|
||||||
const SERVICE_TYPE_CODE = 'RAIL_CONTAINER_PAID_MILE';
|
const SERVICE_TYPE_CODE = 'RAIL_CONTAINER_PAID_MILE';
|
||||||
const COMPANY_TIN = 'PAIDMILE001';
|
// companies.tin is varchar(10) — an 11-char TIN 22001s the whole seeder.
|
||||||
|
const COMPANY_TIN = 'PAIDMILE01';
|
||||||
const COMPANY_EMAIL = 'paid-mile-demo@edr.local';
|
const COMPANY_EMAIL = 'paid-mile-demo@edr.local';
|
||||||
|
|
||||||
const YARDS = [
|
const YARDS = [
|
||||||
@@ -183,6 +189,22 @@ export class PaidImportExportMileDemoSeeder {
|
|||||||
manager.getRepository(ContainerType).find(),
|
manager.getRepository(ContainerType).find(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// bookings.company_profile_id is NOT NULL — the demo company needs an
|
||||||
|
// approved importer profile of its own (no unique key to upsert on).
|
||||||
|
const profileRepo = manager.getRepository(CompanyProfile);
|
||||||
|
const companyProfile =
|
||||||
|
(await profileRepo.findOne({
|
||||||
|
where: { companyId: company.id, type: ProfileType.importer },
|
||||||
|
})) ??
|
||||||
|
(await profileRepo.save(
|
||||||
|
profileRepo.create({
|
||||||
|
companyId: company.id,
|
||||||
|
type: ProfileType.importer,
|
||||||
|
status: ProfileStatus.Active,
|
||||||
|
businessLicense: 'PMD-LIC-0001',
|
||||||
|
}),
|
||||||
|
));
|
||||||
|
|
||||||
const yardByCode = new Map(yards.map((yard) => [yard.code, yard]));
|
const yardByCode = new Map(yards.map((yard) => [yard.code, yard]));
|
||||||
const containerTypeByCode = new Map(
|
const containerTypeByCode = new Map(
|
||||||
containerTypes.map((containerType) => [containerType.code, containerType]),
|
containerTypes.map((containerType) => [containerType.code, containerType]),
|
||||||
@@ -206,6 +228,7 @@ export class PaidImportExportMileDemoSeeder {
|
|||||||
{
|
{
|
||||||
reference: demoBooking.reference,
|
reference: demoBooking.reference,
|
||||||
companyId: company.id,
|
companyId: company.id,
|
||||||
|
companyProfileId: companyProfile.id,
|
||||||
status: 'APPROVED',
|
status: 'APPROVED',
|
||||||
scheduledDate: new Date(demoBooking.scheduledDate),
|
scheduledDate: new Date(demoBooking.scheduledDate),
|
||||||
estimatedShipmentDate: new Date(demoBooking.scheduledDate),
|
estimatedShipmentDate: new Date(demoBooking.scheduledDate),
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
# Dev server port. Default: 5283.
|
||||||
|
PORT=5283
|
||||||
|
|
||||||
VITE_API_URL=http://localhost:3001
|
VITE_API_URL=http://localhost:3001
|
||||||
VITE_BASE_API_URL=http://localhost:3001
|
VITE_BASE_API_URL=http://localhost:3001
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite --port 5183 --clearScreen false",
|
"dev": "vite --clearScreen false",
|
||||||
"prebuild": "node -e \"const fs=require('fs'); fs.rmSync('dist',{recursive:true,force:true});\"",
|
"prebuild": "node -e \"const fs=require('fs'); fs.rmSync('dist',{recursive:true,force:true});\"",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview --port 5183",
|
"preview": "vite preview --port 5183",
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
FileSignature,
|
FileSignature,
|
||||||
FileText,
|
FileText,
|
||||||
Hammer,
|
Hammer,
|
||||||
|
History,
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
LayoutGrid,
|
LayoutGrid,
|
||||||
MapPin,
|
MapPin,
|
||||||
@@ -76,6 +77,7 @@ import ReportsHubPage from "./pages/reports/ReportsHubPage";
|
|||||||
import ReportPage from "./pages/reports/ReportPage";
|
import ReportPage from "./pages/reports/ReportPage";
|
||||||
import AiBookingMockTestPage from "./pages/ai/AiBookingMockTestPage";
|
import AiBookingMockTestPage from "./pages/ai/AiBookingMockTestPage";
|
||||||
import PaymentsPage from "./pages/payments/PaymentsPage";
|
import PaymentsPage from "./pages/payments/PaymentsPage";
|
||||||
|
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 {
|
||||||
@@ -582,6 +584,12 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
|||||||
icon: <ScrollText />,
|
icon: <ScrollText />,
|
||||||
permission: FREIGHT_PERMS.admin,
|
permission: FREIGHT_PERMS.admin,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: "Audit logs",
|
||||||
|
href: "/dashboard/audit-logs",
|
||||||
|
icon: <History />,
|
||||||
|
permission: FREIGHT_PERMS.audit.view,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: "Configuration",
|
label: "Configuration",
|
||||||
href: "/dashboard/configuration",
|
href: "/dashboard/configuration",
|
||||||
@@ -639,6 +647,8 @@ const DJ_CLEARANCE_HREF = "/dashboard/gl-djibouti/clearance";
|
|||||||
const GL_WORKFLOW_PATH_PATTERNS: RegExp[] = [
|
const GL_WORKFLOW_PATH_PATTERNS: RegExp[] = [
|
||||||
/^\/dashboard\/contracts\/[^/]+\/create-booking(\/|$)/,
|
/^\/dashboard\/contracts\/[^/]+\/create-booking(\/|$)/,
|
||||||
/^\/dashboard\/bookings\/[^/]+\/clearance(\/|$)/,
|
/^\/dashboard\/bookings\/[^/]+\/clearance(\/|$)/,
|
||||||
|
// The ET hub's rows open the shipment clearance detail at this URL.
|
||||||
|
/^\/dashboard\/clearance\/[^/]+(\/|$)/,
|
||||||
];
|
];
|
||||||
|
|
||||||
const isEtClearanceItem = (item: SidebarItem): boolean =>
|
const isEtClearanceItem = (item: SidebarItem): boolean =>
|
||||||
@@ -1595,6 +1605,14 @@ const App = () => {
|
|||||||
</RequirePermission>
|
</RequirePermission>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="audit-logs"
|
||||||
|
element={
|
||||||
|
<RequirePermission permission={FREIGHT_PERMS.audit.view}>
|
||||||
|
<AuditLogsPage />
|
||||||
|
</RequirePermission>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="contract-templates"
|
path="contract-templates"
|
||||||
element={
|
element={
|
||||||
|
|||||||
@@ -184,6 +184,13 @@ const ROUTE_META: Array<{ prefix: string; meta: PageMeta }> = [
|
|||||||
subtitle: "Manage dropdown options used across the platform",
|
subtitle: "Manage dropdown options used across the platform",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
prefix: "/dashboard/audit-logs",
|
||||||
|
meta: {
|
||||||
|
title: "Audit Logs",
|
||||||
|
subtitle: "Request and entity-level activity recorded across the freight API",
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
prefix: "/dashboard/configuration/contract-validity-periods",
|
prefix: "/dashboard/configuration/contract-validity-periods",
|
||||||
meta: {
|
meta: {
|
||||||
|
|||||||
@@ -0,0 +1,369 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import {
|
||||||
|
Badge,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Group,
|
||||||
|
Modal,
|
||||||
|
NumberInput,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
Textarea,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import type { ColumnDef } from "@edr/ui-common";
|
||||||
|
import { DataTable, DataTableFooter, usePagination } from "@edr/ui-common";
|
||||||
|
|
||||||
|
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
|
||||||
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
import { useAuth } from "@/auth/useAuth";
|
||||||
|
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
|
||||||
|
import {
|
||||||
|
lastMileRequestsService,
|
||||||
|
type LastMileRequest,
|
||||||
|
type LastMileRequestStatus,
|
||||||
|
} from "@/services/last-mile-requests.service";
|
||||||
|
|
||||||
|
const STATUS_META: Record<LastMileRequestStatus, { label: string; color: string }> = {
|
||||||
|
AWAITING_CONFIRMATION: { label: "Awaiting Confirmation", color: "gray" },
|
||||||
|
SUBMITTED: { label: "Submitted", color: "yellow" },
|
||||||
|
APPROVED: { label: "Approved", color: "green" },
|
||||||
|
REJECTED: { label: "Rejected", color: "red" },
|
||||||
|
};
|
||||||
|
|
||||||
|
type StatusFilter = "ALL" | LastMileRequestStatus;
|
||||||
|
|
||||||
|
const FILTER_OPTIONS: { value: StatusFilter; label: string }[] = [
|
||||||
|
{ value: "SUBMITTED", label: "Submitted" },
|
||||||
|
{ value: "APPROVED", label: "Approved" },
|
||||||
|
{ value: "REJECTED", label: "Rejected" },
|
||||||
|
{ value: "AWAITING_CONFIRMATION", label: "Awaiting Confirmation" },
|
||||||
|
{ value: "ALL", label: "All" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const fmtDate = (iso?: string | null) =>
|
||||||
|
iso ? new Date(iso).toLocaleString("en-US", { dateStyle: "medium", timeStyle: "short" }) : "—";
|
||||||
|
|
||||||
|
export function LastMileRequestsPanel() {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const { user } = useAuth();
|
||||||
|
const canApprove = hasPermission(user, FREIGHT_PERMS.lastMile.requestApprove);
|
||||||
|
|
||||||
|
const [statusFilter, setStatusFilter] = useState<StatusFilter>("SUBMITTED");
|
||||||
|
const { pagination, setPagination } = usePagination({ pageSize: 10 });
|
||||||
|
const [approveTarget, setApproveTarget] = useState<LastMileRequest | null>(null);
|
||||||
|
const [rejectTarget, setRejectTarget] = useState<LastMileRequest | null>(null);
|
||||||
|
const [advanceAmount, setAdvanceAmount] = useState<number | string>("");
|
||||||
|
const [rejectReason, setRejectReason] = useState("");
|
||||||
|
|
||||||
|
const filter = {
|
||||||
|
...(statusFilter !== "ALL" ? { status: statusFilter } : {}),
|
||||||
|
page: pagination.pageIndex + 1,
|
||||||
|
pageSize: pagination.pageSize,
|
||||||
|
};
|
||||||
|
|
||||||
|
const { data, isLoading } = useQuery({
|
||||||
|
queryKey: QUERY_KEYS.LAST_MILE_REQUESTS.list(filter),
|
||||||
|
queryFn: async () => (await lastMileRequestsService.list(filter)).data,
|
||||||
|
});
|
||||||
|
const rows = data?.data ?? [];
|
||||||
|
const meta = data?.meta;
|
||||||
|
|
||||||
|
const { data: freeTrucks } = useQuery({
|
||||||
|
queryKey: QUERY_KEYS.LAST_MILE_REQUESTS.freeTruckCount,
|
||||||
|
queryFn: async () => (await lastMileRequestsService.freeTruckCount()).data,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Rule-based estimate for the approve dialog (estimated km × live last-mile
|
||||||
|
// rates). Prefills the advance once, without clobbering a typed value.
|
||||||
|
const { data: estimate } = useQuery({
|
||||||
|
queryKey: QUERY_KEYS.LAST_MILE_REQUESTS.priceEstimate(approveTarget?.id ?? ""),
|
||||||
|
queryFn: async () =>
|
||||||
|
(await lastMileRequestsService.priceEstimate(approveTarget!.id)).data,
|
||||||
|
enabled: Boolean(approveTarget),
|
||||||
|
});
|
||||||
|
useEffect(() => {
|
||||||
|
if (approveTarget && estimate?.total != null && advanceAmount === "") {
|
||||||
|
setAdvanceAmount(estimate.total);
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [estimate, approveTarget]);
|
||||||
|
|
||||||
|
const invalidate = () =>
|
||||||
|
qc.invalidateQueries({ queryKey: QUERY_KEYS.LAST_MILE_REQUESTS.ROOT });
|
||||||
|
|
||||||
|
const downloadContract = async (r: LastMileRequest) => {
|
||||||
|
try {
|
||||||
|
const { data: blob } = await lastMileRequestsService.contractDocument(r.id);
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = url;
|
||||||
|
a.download = `LM_${r.booking?.company?.name?.replace(/[^A-Za-z0-9._-]+/g, "_") ?? r.id}.pdf`;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
} catch {
|
||||||
|
toast({ title: "Contract PDF not available", variant: "destructive" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const approve = useMutation({
|
||||||
|
mutationFn: () =>
|
||||||
|
lastMileRequestsService.approve(approveTarget!.id, Number(advanceAmount)),
|
||||||
|
onSuccess: () => {
|
||||||
|
void invalidate();
|
||||||
|
toast({ title: "Request approved" });
|
||||||
|
setApproveTarget(null);
|
||||||
|
setAdvanceAmount("");
|
||||||
|
},
|
||||||
|
onError: (e: unknown) => {
|
||||||
|
const description = (e as { response?: { data?: { message?: string } } })?.response?.data?.message;
|
||||||
|
toast({ title: "Approve failed", description, variant: "destructive" });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const reject = useMutation({
|
||||||
|
mutationFn: () => lastMileRequestsService.reject(rejectTarget!.id, rejectReason.trim()),
|
||||||
|
onSuccess: () => {
|
||||||
|
void invalidate();
|
||||||
|
toast({ title: "Request rejected" });
|
||||||
|
setRejectTarget(null);
|
||||||
|
setRejectReason("");
|
||||||
|
},
|
||||||
|
onError: (e: unknown) => {
|
||||||
|
const description = (e as { response?: { data?: { message?: string } } })?.response?.data?.message;
|
||||||
|
toast({ title: "Reject failed", description, variant: "destructive" });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const columns: ColumnDef<LastMileRequest>[] = [
|
||||||
|
{
|
||||||
|
id: "booking",
|
||||||
|
header: () => <span>Booking</span>,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const r = row.original;
|
||||||
|
return (
|
||||||
|
<Stack gap={0}>
|
||||||
|
<Text size="sm" fw={600}>{r.booking?.reference ?? r.bookingId}</Text>
|
||||||
|
<Text size="xs" c="dimmed">{r.booking?.company?.name ?? "—"}</Text>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "containers",
|
||||||
|
header: () => <span>Requested Containers</span>,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const r = row.original;
|
||||||
|
const nums = r.requestedContainerNumbers;
|
||||||
|
return (
|
||||||
|
<Stack gap={0}>
|
||||||
|
<Text size="sm">{nums?.length ? nums.join(", ") : "—"}</Text>
|
||||||
|
{r.requestedDeliveryDate && (
|
||||||
|
<Text size="xs" c="dimmed">Delivery: {r.requestedDeliveryDate}</Text>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "submittedAt",
|
||||||
|
header: () => <span>Submitted</span>,
|
||||||
|
cell: ({ row }) => <Text size="sm">{fmtDate(row.original.submittedAt)}</Text>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "status",
|
||||||
|
header: () => <span>Status</span>,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const meta = STATUS_META[row.original.status];
|
||||||
|
return (
|
||||||
|
<Badge color={meta.color} variant="light" size="sm">
|
||||||
|
{meta.label}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "contract",
|
||||||
|
header: () => <span>LM Contract</span>,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const r = row.original;
|
||||||
|
if (r.status !== "APPROVED") return <Text size="sm" c="dimmed">—</Text>;
|
||||||
|
return (
|
||||||
|
<Stack gap={4} align="flex-start">
|
||||||
|
<Badge color={r.customerSignedAt ? "green" : "yellow"} variant="light" size="sm">
|
||||||
|
{r.customerSignedAt ? "Signed" : "Awaiting signature"}
|
||||||
|
</Badge>
|
||||||
|
<Button size="compact-xs" variant="subtle" onClick={() => void downloadContract(r)}>
|
||||||
|
Download PDF
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
...(canApprove
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
id: "actions",
|
||||||
|
header: () => <span>Actions</span>,
|
||||||
|
cell: ({ row }: { row: { original: LastMileRequest } }) => {
|
||||||
|
const r = row.original;
|
||||||
|
if (r.status !== "SUBMITTED") return null;
|
||||||
|
return (
|
||||||
|
<Group gap="xs">
|
||||||
|
<Button size="xs" variant="light" color="green" onClick={() => setApproveTarget(r)}>
|
||||||
|
Approve
|
||||||
|
</Button>
|
||||||
|
<Button size="xs" variant="light" color="red" onClick={() => setRejectTarget(r)}>
|
||||||
|
Reject
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
} as ColumnDef<LastMileRequest>,
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card radius="lg" padding={0} withBorder style={{ borderColor: "var(--mantine-color-gray-2)" }}>
|
||||||
|
<Stack gap={0}>
|
||||||
|
<Box px="md" pt="md" pb="sm" w="100%">
|
||||||
|
<Stack gap="sm">
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
{freeTrucks?.count ?? 0} truck{freeTrucks?.count === 1 ? "" : "s"} currently free
|
||||||
|
</Text>
|
||||||
|
<Group gap="xs" wrap="wrap">
|
||||||
|
{FILTER_OPTIONS.map((option) => {
|
||||||
|
const active = statusFilter === option.value;
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
key={option.value}
|
||||||
|
size="xs"
|
||||||
|
variant={active ? "filled" : "default"}
|
||||||
|
styles={{ label: { fontWeight: 500 } }}
|
||||||
|
onClick={() => {
|
||||||
|
setStatusFilter(option.value);
|
||||||
|
setPagination((p) => ({ ...p, pageIndex: 0 }));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<DataTable
|
||||||
|
columns={columns}
|
||||||
|
data={rows}
|
||||||
|
status={isLoading ? "loading" : "success"}
|
||||||
|
emptyMessage="No last-mile confirmation requests found"
|
||||||
|
pagination={{
|
||||||
|
pageIndex: pagination.pageIndex,
|
||||||
|
pageSize: pagination.pageSize,
|
||||||
|
pageCount: meta?.totalPages ?? 1,
|
||||||
|
totalCount: meta?.total ?? 0,
|
||||||
|
}}
|
||||||
|
tableOptions={{
|
||||||
|
manualPagination: true,
|
||||||
|
pageCount: meta?.totalPages ?? 1,
|
||||||
|
state: { pagination },
|
||||||
|
onPaginationChange: setPagination,
|
||||||
|
}}
|
||||||
|
containerClassName="border-0 shadow-none bg-transparent"
|
||||||
|
footer={({ table, pagination: fp }) => (
|
||||||
|
<DataTableFooter table={table} pagination={fp} options={{ labels: { items: "requests" } }} />
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
opened={Boolean(approveTarget)}
|
||||||
|
onClose={() => {
|
||||||
|
setApproveTarget(null);
|
||||||
|
setAdvanceAmount("");
|
||||||
|
}}
|
||||||
|
title={<Text fw={700}>Approve request{approveTarget?.booking?.reference ? ` · ${approveTarget.booking.reference}` : ""}</Text>}
|
||||||
|
centered
|
||||||
|
>
|
||||||
|
<Stack gap="md">
|
||||||
|
{estimate?.total != null && (
|
||||||
|
<Stack gap={4}>
|
||||||
|
{estimate.lines.map((line) => (
|
||||||
|
<Text key={line.description} size="xs" c="dimmed">
|
||||||
|
{line.description} — {line.amount.toLocaleString()}
|
||||||
|
</Text>
|
||||||
|
))}
|
||||||
|
<Text size="sm" fw={600}>
|
||||||
|
Estimated total: {estimate.total.toLocaleString()} {estimate.currency}
|
||||||
|
{estimate.estimatedKm != null
|
||||||
|
? ` · ${estimate.estimatedKm} km (straight-line estimate)`
|
||||||
|
: ""}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
<NumberInput
|
||||||
|
label="Advance amount"
|
||||||
|
placeholder="0.00"
|
||||||
|
required
|
||||||
|
min={0.01}
|
||||||
|
value={advanceAmount}
|
||||||
|
onChange={setAdvanceAmount}
|
||||||
|
/>
|
||||||
|
<Group justify="flex-end">
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
onClick={() => {
|
||||||
|
setApproveTarget(null);
|
||||||
|
setAdvanceAmount("");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
disabled={!(Number(advanceAmount) > 0)}
|
||||||
|
loading={approve.isPending}
|
||||||
|
onClick={() => approve.mutate()}
|
||||||
|
>
|
||||||
|
Confirm
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
opened={Boolean(rejectTarget)}
|
||||||
|
onClose={() => setRejectTarget(null)}
|
||||||
|
title={<Text fw={700}>Reject request{rejectTarget?.booking?.reference ? ` · ${rejectTarget.booking.reference}` : ""}</Text>}
|
||||||
|
centered
|
||||||
|
>
|
||||||
|
<Stack gap="md">
|
||||||
|
<Textarea
|
||||||
|
label="Reason"
|
||||||
|
placeholder="Why is this request being rejected?"
|
||||||
|
required
|
||||||
|
minRows={3}
|
||||||
|
value={rejectReason}
|
||||||
|
onChange={(e) => setRejectReason(e.currentTarget.value)}
|
||||||
|
/>
|
||||||
|
<Group justify="flex-end">
|
||||||
|
<Button variant="default" onClick={() => setRejectTarget(null)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
color="red"
|
||||||
|
disabled={!rejectReason.trim()}
|
||||||
|
loading={reject.isPending}
|
||||||
|
onClick={() => reject.mutate()}
|
||||||
|
>
|
||||||
|
Confirm
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Modal>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -234,7 +234,7 @@ const RuleEngineCardGrid = ({
|
|||||||
{col.header}:
|
{col.header}:
|
||||||
</Text>
|
</Text>
|
||||||
<div style={{ textAlign: "right", flex: 1 }}>
|
<div style={{ textAlign: "right", flex: 1 }}>
|
||||||
{formatCell(displayValue, col.format)}
|
{formatCell(displayValue, col.format, record)}
|
||||||
</div>
|
</div>
|
||||||
</Group>
|
</Group>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { Loader2 } from "lucide-react";
|
import { Loader2, Plus, Trash2 } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
|
ActionIcon,
|
||||||
Modal,
|
Modal,
|
||||||
Button,
|
Button,
|
||||||
TextInput,
|
TextInput,
|
||||||
@@ -41,6 +42,37 @@ type FormRow =
|
|||||||
| { kind: "pair"; fields: [FormFieldDef, FormFieldDef] }
|
| { kind: "pair"; fields: [FormFieldDef, FormFieldDef] }
|
||||||
| { kind: "single"; field: FormFieldDef };
|
| { kind: "single"; field: FormFieldDef };
|
||||||
|
|
||||||
|
/** One editable distance tier of a tierList field (raw input strings). */
|
||||||
|
type TierRow = { minKm: string; maxKm: string; rateValue: string };
|
||||||
|
|
||||||
|
const emptyTier = (fromKm = ""): TierRow => ({ minKm: fromKm, maxKm: "", rateValue: "" });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate a tier set before submit: every tier complete, ranges sane, no
|
||||||
|
* overlaps, and only the last tier open-ended. Returns the error message, or
|
||||||
|
* null when the set is valid.
|
||||||
|
*/
|
||||||
|
const validateTiers = (rows: TierRow[]): string | null => {
|
||||||
|
if (!rows.length) return "Add at least one tier.";
|
||||||
|
for (const row of rows) {
|
||||||
|
if (row.minKm === "" || row.rateValue === "") {
|
||||||
|
return "Every tier needs a From km and a Rate value.";
|
||||||
|
}
|
||||||
|
if (row.maxKm !== "" && Number(row.maxKm) <= Number(row.minKm)) {
|
||||||
|
return "Each tier's To km must be greater than its From km.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const sorted = [...rows].sort((a, b) => Number(a.minKm) - Number(b.minKm));
|
||||||
|
for (let i = 1; i < sorted.length; i += 1) {
|
||||||
|
const prev = sorted[i - 1];
|
||||||
|
if (prev.maxKm === "") return "Only the last tier can leave To km empty.";
|
||||||
|
if (Number(sorted[i].minKm) < Number(prev.maxKm)) {
|
||||||
|
return `Tiers overlap around ${sorted[i].minKm} km — each distance must fall in exactly one tier.`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
const isShortField = (field: FormFieldDef) =>
|
const isShortField = (field: FormFieldDef) =>
|
||||||
field.type === "text" ||
|
field.type === "text" ||
|
||||||
field.type === "number" ||
|
field.type === "number" ||
|
||||||
@@ -54,7 +86,7 @@ const buildFormRows = (fields: FormFieldDef[]): FormRow[] => {
|
|||||||
while (index < fields.length) {
|
while (index < fields.length) {
|
||||||
const field = fields[index];
|
const field = fields[index];
|
||||||
|
|
||||||
if (field.type === "textarea" || field.type === "boolean") {
|
if (field.type === "textarea" || field.type === "boolean" || field.type === "tierList") {
|
||||||
rows.push({ kind: "single", field });
|
rows.push({ kind: "single", field });
|
||||||
index += 1;
|
index += 1;
|
||||||
continue;
|
continue;
|
||||||
@@ -85,6 +117,8 @@ const buildInitialValues = (
|
|||||||
: record?.[field.name];
|
: record?.[field.name];
|
||||||
if (field.type === "multiselect") {
|
if (field.type === "multiselect") {
|
||||||
values[field.name] = Array.isArray(raw) ? raw.map(String) : [];
|
values[field.name] = Array.isArray(raw) ? raw.map(String) : [];
|
||||||
|
} else if (field.type === "tierList") {
|
||||||
|
values[field.name] = [emptyTier("0")];
|
||||||
} else if (raw !== undefined && raw !== null) {
|
} else if (raw !== undefined && raw !== null) {
|
||||||
if (field.type === "date" && typeof raw === "string") {
|
if (field.type === "date" && typeof raw === "string") {
|
||||||
values[field.name] = raw.slice(0, 10);
|
values[field.name] = raw.slice(0, 10);
|
||||||
@@ -93,6 +127,8 @@ const buildInitialValues = (
|
|||||||
} else {
|
} else {
|
||||||
values[field.name] = raw;
|
values[field.name] = raw;
|
||||||
}
|
}
|
||||||
|
} else if (field.defaultValue !== undefined) {
|
||||||
|
values[field.name] = field.defaultValue;
|
||||||
} else if (field.type === "boolean") {
|
} else if (field.type === "boolean") {
|
||||||
values[field.name] = false;
|
values[field.name] = false;
|
||||||
} else if (field.type === "number") {
|
} else if (field.type === "number") {
|
||||||
@@ -241,6 +277,19 @@ const RuleEngineFormDialog = ({
|
|||||||
if (field.type === "multiselect") {
|
if (field.type === "multiselect") {
|
||||||
// Always the full replacement list — the API syncs the relation to it.
|
// Always the full replacement list — the API syncs the relation to it.
|
||||||
payload[field.name] = Array.isArray(raw) ? raw : [];
|
payload[field.name] = Array.isArray(raw) ? raw : [];
|
||||||
|
} else if (field.type === "tierList") {
|
||||||
|
const rows = (Array.isArray(raw) ? raw : []) as TierRow[];
|
||||||
|
const error = validateTiers(rows);
|
||||||
|
if (error) {
|
||||||
|
setFieldErrors((current) => ({ ...current, [field.name]: error }));
|
||||||
|
blocked = true;
|
||||||
|
} else {
|
||||||
|
payload[field.name] = rows.map((row) => ({
|
||||||
|
minKm: Number(row.minKm),
|
||||||
|
maxKm: row.maxKm === "" ? null : Number(row.maxKm),
|
||||||
|
rateValue: Number(row.rateValue),
|
||||||
|
}));
|
||||||
|
}
|
||||||
} else if (field.type === "number") {
|
} else if (field.type === "number") {
|
||||||
if (raw === "" || raw === undefined) continue;
|
if (raw === "" || raw === undefined) continue;
|
||||||
payload[field.name] = Number(raw);
|
payload[field.name] = Number(raw);
|
||||||
@@ -313,6 +362,101 @@ const RuleEngineFormDialog = ({
|
|||||||
|
|
||||||
const label = <FieldLabel label={field.label} required={field.required} />;
|
const label = <FieldLabel label={field.label} required={field.required} />;
|
||||||
|
|
||||||
|
if (field.type === "tierList") {
|
||||||
|
const rows = Array.isArray(values[field.name])
|
||||||
|
? (values[field.name] as TierRow[])
|
||||||
|
: [];
|
||||||
|
const setRows = (next: TierRow[]) => setField(field.name, next);
|
||||||
|
const setRow = (index: number, key: keyof TierRow, value: string) => {
|
||||||
|
if (value.trim().startsWith("-")) return;
|
||||||
|
setRows(rows.map((row, i) => (i === index ? { ...row, [key]: value } : row)));
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<Box key={field.name}>
|
||||||
|
<Text size="sm" fw={600} mb={2} c="var(--mantine-color-gray-8)">
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
{field.description ? (
|
||||||
|
<Text size="xs" c="dimmed" mb={8}>
|
||||||
|
{field.description}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
<Stack gap="xs">
|
||||||
|
{rows.map((row, index) => (
|
||||||
|
<Group key={index} gap="xs" wrap="nowrap" align="flex-end">
|
||||||
|
<TextInput
|
||||||
|
label={index === 0 ? "From km" : undefined}
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
step="any"
|
||||||
|
placeholder="0"
|
||||||
|
value={row.minKm}
|
||||||
|
onChange={(e) => setRow(index, "minKm", e.currentTarget.value)}
|
||||||
|
size="md"
|
||||||
|
radius="md"
|
||||||
|
styles={inputStyles}
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
label={index === 0 ? "To km" : undefined}
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
step="any"
|
||||||
|
placeholder="No limit"
|
||||||
|
value={row.maxKm}
|
||||||
|
onChange={(e) => setRow(index, "maxKm", e.currentTarget.value)}
|
||||||
|
size="md"
|
||||||
|
radius="md"
|
||||||
|
styles={inputStyles}
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
label={index === 0 ? "Rate value" : undefined}
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
step="any"
|
||||||
|
placeholder="Rate per km"
|
||||||
|
value={row.rateValue}
|
||||||
|
onChange={(e) => setRow(index, "rateValue", e.currentTarget.value)}
|
||||||
|
size="md"
|
||||||
|
radius="md"
|
||||||
|
styles={inputStyles}
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
/>
|
||||||
|
<ActionIcon
|
||||||
|
variant="subtle"
|
||||||
|
color="red"
|
||||||
|
size="lg"
|
||||||
|
mb={2}
|
||||||
|
aria-label="Remove tier"
|
||||||
|
disabled={rows.length === 1}
|
||||||
|
onClick={() => setRows(rows.filter((_, i) => i !== index))}
|
||||||
|
>
|
||||||
|
<Trash2 size={16} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Group>
|
||||||
|
))}
|
||||||
|
<Group justify="flex-start">
|
||||||
|
<Button
|
||||||
|
variant="light"
|
||||||
|
size="xs"
|
||||||
|
leftSection={<Plus size={14} />}
|
||||||
|
// The next tier naturally starts where the previous one ends.
|
||||||
|
onClick={() => setRows([...rows, emptyTier(rows[rows.length - 1]?.maxKm ?? "")])}
|
||||||
|
>
|
||||||
|
Add tier
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
{fieldErrors[field.name] ? (
|
||||||
|
<Text size="xs" c="red">
|
||||||
|
{fieldErrors[field.name]}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (field.type === "multiselect") {
|
if (field.type === "multiselect") {
|
||||||
const options = field.optionsFromValues
|
const options = field.optionsFromValues
|
||||||
? field.optionsFromValues(values)
|
? field.optionsFromValues(values)
|
||||||
|
|||||||
@@ -17,7 +17,13 @@ const extractLabel = (value: unknown): string | null => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const formatCell = (value: unknown, format?: ColumnFormat): ReactNode => {
|
export const formatCell = (
|
||||||
|
value: unknown,
|
||||||
|
format?: ColumnFormat,
|
||||||
|
// The row the cell came from — currency amounts read their code off it so a
|
||||||
|
// last-mile rate priced in birr does not render as USD.
|
||||||
|
row?: Record<string, unknown>,
|
||||||
|
): ReactNode => {
|
||||||
if (value === null || value === undefined || value === "") {
|
if (value === null || value === undefined || value === "") {
|
||||||
return <Text size="sm" c="dimmed">—</Text>;
|
return <Text size="sm" c="dimmed">—</Text>;
|
||||||
}
|
}
|
||||||
@@ -109,9 +115,10 @@ export const formatCell = (value: unknown, format?: ColumnFormat): ReactNode =>
|
|||||||
|
|
||||||
if (format === "currency") {
|
if (format === "currency") {
|
||||||
const num = Number(value);
|
const num = Number(value);
|
||||||
|
const code = typeof row?.currency === "string" ? row.currency : "USD";
|
||||||
return (
|
return (
|
||||||
<Text size="sm" fw={500}>
|
<Text size="sm" fw={500}>
|
||||||
{Number.isNaN(num) ? String(value) : `USD ${num.toLocaleString()}`}
|
{Number.isNaN(num) ? String(value) : `${code} ${num.toLocaleString()}`}
|
||||||
</Text>
|
</Text>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -155,6 +155,15 @@ export const QUERY_KEYS = {
|
|||||||
byId: (id: string) => ["last-mile", "detail", id] as const,
|
byId: (id: string) => ["last-mile", "detail", id] as const,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
LAST_MILE_REQUESTS: {
|
||||||
|
ROOT: ["last-mile-requests"] as const,
|
||||||
|
list: (filter?: Record<string, unknown>) =>
|
||||||
|
["last-mile-requests", "list", filter ?? {}] as const,
|
||||||
|
freeTruckCount: ["last-mile-requests", "free-truck-count"] as const,
|
||||||
|
priceEstimate: (id: string) =>
|
||||||
|
["last-mile-requests", "price-estimate", id] as const,
|
||||||
|
},
|
||||||
|
|
||||||
RULE_ENGINE: {
|
RULE_ENGINE: {
|
||||||
ROOT: ["rule-engine"] as const,
|
ROOT: ["rule-engine"] as const,
|
||||||
list: (
|
list: (
|
||||||
|
|||||||
@@ -309,6 +309,10 @@ export const URL_CONSTANTS = {
|
|||||||
SUMMARY: "/payments/summary",
|
SUMMARY: "/payments/summary",
|
||||||
},
|
},
|
||||||
|
|
||||||
|
AUDIT: {
|
||||||
|
LOGS: "/audit/logs",
|
||||||
|
},
|
||||||
|
|
||||||
LOCOMOTIVES: {
|
LOCOMOTIVES: {
|
||||||
BASE: "/locomotives",
|
BASE: "/locomotives",
|
||||||
BY_ID: (id: string) => `/locomotives/${id}`,
|
BY_ID: (id: string) => `/locomotives/${id}`,
|
||||||
@@ -703,6 +707,16 @@ export const URL_CONSTANTS = {
|
|||||||
PROOF_OF_DELIVERY: (id: string) => `/last-mile/${id}/proof-of-delivery`,
|
PROOF_OF_DELIVERY: (id: string) => `/last-mile/${id}/proof-of-delivery`,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
LAST_MILE_REQUESTS: {
|
||||||
|
BASE: "/last-mile-requests",
|
||||||
|
BY_ID: (id: string) => `/last-mile-requests/${id}`,
|
||||||
|
FREE_TRUCK_COUNT: "/last-mile-requests/free-truck-count",
|
||||||
|
PRICE_ESTIMATE: (id: string) => `/last-mile-requests/${id}/price-estimate`,
|
||||||
|
APPROVE: (id: string) => `/last-mile-requests/${id}/approve`,
|
||||||
|
REJECT: (id: string) => `/last-mile-requests/${id}/reject`,
|
||||||
|
CONTRACT_DOCUMENT: (id: string) => `/last-mile-requests/${id}/contract/document`,
|
||||||
|
},
|
||||||
|
|
||||||
DRIVERS: {
|
DRIVERS: {
|
||||||
BASE: "/drivers",
|
BASE: "/drivers",
|
||||||
BY_ID: (id: string) => `/drivers/${id}`,
|
BY_ID: (id: string) => `/drivers/${id}`,
|
||||||
|
|||||||
@@ -116,6 +116,9 @@ export const FREIGHT_PERMS = {
|
|||||||
assignVehicles: "edr_freight_app:last_mile:assign_vehicles",
|
assignVehicles: "edr_freight_app:last_mile:assign_vehicles",
|
||||||
setDistances: "edr_freight_app:last_mile:set_distances",
|
setDistances: "edr_freight_app:last_mile:set_distances",
|
||||||
generateInvoice: "edr_freight_app:last_mile:generate_invoice",
|
generateInvoice: "edr_freight_app:last_mile:generate_invoice",
|
||||||
|
requestView: "edr_freight_app:last_mile:request_view",
|
||||||
|
requestReview: "edr_freight_app:last_mile:request_review",
|
||||||
|
requestApprove: "edr_freight_app:last_mile:request_approve",
|
||||||
},
|
},
|
||||||
locomotives: {
|
locomotives: {
|
||||||
view: "edr_freight_app:locomotives:view",
|
view: "edr_freight_app:locomotives:view",
|
||||||
@@ -276,6 +279,9 @@ export const FREIGHT_PERMS = {
|
|||||||
manage: "edr_freight_app:settings:dropdown:manage",
|
manage: "edr_freight_app:settings:dropdown:manage",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
audit: {
|
||||||
|
view: "edr_freight_app:audit:view",
|
||||||
|
},
|
||||||
staff: {
|
staff: {
|
||||||
roles: {
|
roles: {
|
||||||
view: "edr_freight_app:staff:roles:view",
|
view: "edr_freight_app:staff:roles:view",
|
||||||
|
|||||||
@@ -0,0 +1,190 @@
|
|||||||
|
import { Badge, Box, Card, Stack, Text } from "@mantine/core";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
import { PageContainer, PageHeader } from "@/components/page";
|
||||||
|
import { api } from "@/services/api";
|
||||||
|
import type {
|
||||||
|
AuditLogRow,
|
||||||
|
AuditQueryMethod,
|
||||||
|
AuditUser,
|
||||||
|
LocalizedText,
|
||||||
|
} from "@/services/audit.service";
|
||||||
|
import {
|
||||||
|
DataTable,
|
||||||
|
DataTableFooter,
|
||||||
|
usePagination,
|
||||||
|
type ColumnDef,
|
||||||
|
} from "@edr/ui-common";
|
||||||
|
|
||||||
|
const ACTION_LABELS: Record<AuditQueryMethod, string> = {
|
||||||
|
INSERT: "Created",
|
||||||
|
UPDATE: "Updated",
|
||||||
|
DELETE: "Deleted",
|
||||||
|
INSERT_CHILD: "Linked child",
|
||||||
|
DELETE_CHILD: "Unlinked child",
|
||||||
|
};
|
||||||
|
|
||||||
|
const ACTION_COLORS: Record<AuditQueryMethod, string> = {
|
||||||
|
INSERT: "edr-green",
|
||||||
|
UPDATE: "yellow",
|
||||||
|
DELETE: "red",
|
||||||
|
INSERT_CHILD: "indigo",
|
||||||
|
DELETE_CHILD: "gray",
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatDateTime(iso: string): string {
|
||||||
|
const d = new Date(iso);
|
||||||
|
return Number.isNaN(d.getTime())
|
||||||
|
? "—"
|
||||||
|
: d.toLocaleString(undefined, {
|
||||||
|
year: "numeric",
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// See LocalizedText: `name`/`title` lifted from a raw audited entity can be
|
||||||
|
// a plain string or IAM's { am, en } — never render either directly.
|
||||||
|
// "undefined undefined" is the producer's own broken template when no user
|
||||||
|
// was attached at all (unauthenticated/customer flows, e.g. Fayda
|
||||||
|
// verification) — filtered out here rather than shown as raw garbage.
|
||||||
|
function localize(value: LocalizedText | null | undefined): string | undefined {
|
||||||
|
if (!value) return undefined;
|
||||||
|
if (typeof value === "object") return value.en ?? value.am ?? undefined;
|
||||||
|
if (/^undefined(\s+undefined)?$/.test(value.trim())) return undefined;
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatUser(user: AuditUser | null | undefined): string {
|
||||||
|
return localize(user?.name) ?? user?.id ?? "—";
|
||||||
|
}
|
||||||
|
|
||||||
|
function summarize(row: AuditLogRow): string {
|
||||||
|
if (row.changes?.length) {
|
||||||
|
return row.changes
|
||||||
|
.slice(0, 2)
|
||||||
|
.map((c) => c.field)
|
||||||
|
.join(", ") + (row.changes.length > 2 ? `, +${row.changes.length - 2} more` : "");
|
||||||
|
}
|
||||||
|
if (row.payload) {
|
||||||
|
return (
|
||||||
|
localize(row.payload.name) ?? localize(row.payload.title) ?? row.payload.id ?? "—"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return "—";
|
||||||
|
}
|
||||||
|
|
||||||
|
const tableHeader =
|
||||||
|
"text-xs font-semibold uppercase tracking-wide text-muted-foreground";
|
||||||
|
|
||||||
|
export default function AuditLogsPage() {
|
||||||
|
const { pagination, setPagination } = usePagination({ pageSize: 20 });
|
||||||
|
|
||||||
|
const filter = {
|
||||||
|
skip: pagination.pageIndex * pagination.pageSize,
|
||||||
|
take: pagination.pageSize,
|
||||||
|
};
|
||||||
|
|
||||||
|
const { data, isLoading, isError } = useQuery(
|
||||||
|
api.audit.list.queryOptions({ input: { filter } }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const rows = data?.items ?? [];
|
||||||
|
const total = data?.count ?? 0;
|
||||||
|
const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize));
|
||||||
|
|
||||||
|
const columns: ColumnDef<AuditLogRow>[] = [
|
||||||
|
{
|
||||||
|
id: "time",
|
||||||
|
header: () => <span className={tableHeader}>Time</span>,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{formatDateTime(row.original.createdAt)}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "action",
|
||||||
|
header: () => <span className={tableHeader}>Action</span>,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Badge
|
||||||
|
color={ACTION_COLORS[row.original.queryMethod] ?? "gray"}
|
||||||
|
variant="light"
|
||||||
|
radius="sm"
|
||||||
|
>
|
||||||
|
{ACTION_LABELS[row.original.queryMethod] ?? row.original.queryMethod}
|
||||||
|
</Badge>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "entity",
|
||||||
|
header: () => <span className={tableHeader}>Entity</span>,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="font-mono text-sm text-foreground">
|
||||||
|
{row.original.entityName}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "user",
|
||||||
|
header: () => <span className={tableHeader}>User</span>,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="text-sm text-foreground">
|
||||||
|
{formatUser(row.original.auditLog?.user)}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "summary",
|
||||||
|
header: () => <span className={tableHeader}>Summary</span>,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="truncate text-sm text-muted-foreground">
|
||||||
|
{summarize(row.original)}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageContainer>
|
||||||
|
<PageHeader
|
||||||
|
title="Audit Logs"
|
||||||
|
subtitle="Request and entity-level activity recorded across the freight API."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Card p={0}>
|
||||||
|
<Stack gap={0}>
|
||||||
|
<Box px="md" pt="md" pb="sm" w="100%">
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
{total} record{total !== 1 ? "s" : ""}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box style={{ overflowX: "auto" }} w="100%">
|
||||||
|
<DataTable
|
||||||
|
columns={columns}
|
||||||
|
data={rows}
|
||||||
|
status={isLoading ? "loading" : isError ? "error" : "success"}
|
||||||
|
pagination={{
|
||||||
|
pageIndex: pagination.pageIndex,
|
||||||
|
pageSize: pagination.pageSize,
|
||||||
|
pageCount,
|
||||||
|
totalCount: total,
|
||||||
|
}}
|
||||||
|
tableOptions={{
|
||||||
|
state: { pagination },
|
||||||
|
onPaginationChange: setPagination,
|
||||||
|
manualPagination: true,
|
||||||
|
pageCount,
|
||||||
|
}}
|
||||||
|
containerClassName="border-0 shadow-none bg-transparent"
|
||||||
|
footer={DataTableFooter}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
</PageContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -47,6 +47,9 @@ import { bookingsService } from "@/services/bookings.service";
|
|||||||
import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
|
import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
|
||||||
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
|
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
import { useAuth } from "@/auth/useAuth";
|
||||||
|
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
|
||||||
|
import { LastMileRequestsPanel } from "@/components/operations/LastMileRequestsPanel";
|
||||||
import {
|
import {
|
||||||
LAST_MILE_STATUSES,
|
LAST_MILE_STATUSES,
|
||||||
type LastMileApiStatus,
|
type LastMileApiStatus,
|
||||||
@@ -543,6 +546,9 @@ const buildTripSlipHtml = (record: LastMileRecord, vehicle?: TripSlipVehicle | n
|
|||||||
const LastMilePage = () => {
|
const LastMilePage = () => {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
|
const { user } = useAuth();
|
||||||
|
const canViewRequests = hasPermission(user, FREIGHT_PERMS.lastMile.requestView);
|
||||||
|
const [view, setView] = useState<"legs" | "requests">("legs");
|
||||||
const [podRecord, setPodRecord] = useState<LastMileRecord | null>(null);
|
const [podRecord, setPodRecord] = useState<LastMileRecord | null>(null);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { pagination, setPagination } = usePagination({ pageSize: 10 });
|
const { pagination, setPagination } = usePagination({ pageSize: 10 });
|
||||||
@@ -1590,6 +1596,30 @@ const LastMilePage = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack gap="md" p="md">
|
<Stack gap="md" p="md">
|
||||||
|
{canViewRequests && (
|
||||||
|
<Group gap="xs">
|
||||||
|
<Button
|
||||||
|
size="xs"
|
||||||
|
variant={view === "legs" ? "filled" : "default"}
|
||||||
|
styles={{ label: { fontWeight: 500 } }}
|
||||||
|
onClick={() => setView("legs")}
|
||||||
|
>
|
||||||
|
Deliveries
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="xs"
|
||||||
|
variant={view === "requests" ? "filled" : "default"}
|
||||||
|
styles={{ label: { fontWeight: 500 } }}
|
||||||
|
onClick={() => setView("requests")}
|
||||||
|
>
|
||||||
|
Requests
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{view === "requests" && canViewRequests ? (
|
||||||
|
<LastMileRequestsPanel />
|
||||||
|
) : (
|
||||||
<Card radius="lg" padding={0} withBorder style={{ borderColor: "var(--mantine-color-gray-2)" }}>
|
<Card radius="lg" padding={0} withBorder style={{ borderColor: "var(--mantine-color-gray-2)" }}>
|
||||||
<Stack gap={0}>
|
<Stack gap={0}>
|
||||||
<Box px="md" pt="md" pb="sm" w="100%">
|
<Box px="md" pt="md" pb="sm" w="100%">
|
||||||
@@ -1672,6 +1702,7 @@ const LastMilePage = () => {
|
|||||||
/>
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Card>
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 2-step Assign Mile (arrival queue → vehicle) */}
|
{/* 2-step Assign Mile (arrival queue → vehicle) */}
|
||||||
<Modal
|
<Modal
|
||||||
|
|||||||
@@ -306,7 +306,27 @@ const RuleEngineResourcePage = () => {
|
|||||||
|
|
||||||
const formFields = useMemo(() => {
|
const formFields = useMemo(() => {
|
||||||
if (!config) return [];
|
if (!config) return [];
|
||||||
return config.formFields.map((field) => {
|
// Last-mile bands (both modes): creating uses the multi-row tier list (one
|
||||||
|
// rate per tier, each tier carrying its own rate value); editing an
|
||||||
|
// existing band row keeps the single From/To/value fields (a rate row IS
|
||||||
|
// one band).
|
||||||
|
const bandFields = config.formFields.filter((field) => {
|
||||||
|
if (config.slug !== "rates") return true;
|
||||||
|
if (field.type === "tierList") return !editing;
|
||||||
|
if (editing) return true;
|
||||||
|
// On create the tier rows carry From/To/value — drop the single fields,
|
||||||
|
// including the last-mile "Rate value" (the non-last-mile one keeps its
|
||||||
|
// own showIf).
|
||||||
|
if (
|
||||||
|
field.name === "rateValue" &&
|
||||||
|
field.showWhen?.field === "appliesTo" &&
|
||||||
|
field.showWhen.equals.includes("LAST_MILE")
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return field.name !== "minKm" && field.name !== "maxKm";
|
||||||
|
});
|
||||||
|
return bandFields.map((field) => {
|
||||||
if (isPriorityRules && field.name === "minWagonCount") {
|
if (isPriorityRules && field.name === "minWagonCount") {
|
||||||
return {
|
return {
|
||||||
...field,
|
...field,
|
||||||
@@ -474,7 +494,7 @@ const RuleEngineResourcePage = () => {
|
|||||||
header: col.header,
|
header: col.header,
|
||||||
meta: { headerClassName, cellClassName },
|
meta: { headerClassName, cellClassName },
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const cell = formatCell(row.original[col.accessorKey], col.format);
|
const cell = formatCell(row.original[col.accessorKey], col.format, row.original);
|
||||||
// On the rate column, show the proposed value under the live one — the
|
// On the rate column, show the proposed value under the live one — the
|
||||||
// live value stays the headline because it is what still gets charged.
|
// live value stays the headline because it is what still gets charged.
|
||||||
if (!isRates || col.accessorKey !== "rateValue") return cell;
|
if (!isRates || col.accessorKey !== "rateValue") return cell;
|
||||||
@@ -584,10 +604,26 @@ const RuleEngineResourcePage = () => {
|
|||||||
// treats them as ALWAYS. Surcharges (Applies to = Other) keep their
|
// treats them as ALWAYS. Surcharges (Applies to = Other) keep their
|
||||||
// chosen trigger.
|
// chosen trigger.
|
||||||
const isSurcharge = values.appliesTo === "OTHER";
|
const isSurcharge = values.appliesTo === "OTHER";
|
||||||
|
// Last mile: the form's calculation mode picks the unit (bulk = per
|
||||||
|
// ton·km, container = per km + distance band) and the currency stays as
|
||||||
|
// chosen (birr or dollar). Everything else remains USD-only.
|
||||||
|
const isLastMile = values.appliesTo === "LAST_MILE";
|
||||||
|
const { lastMileMode, ...rest } = values;
|
||||||
payload = {
|
payload = {
|
||||||
...values,
|
...rest,
|
||||||
currency: "USD",
|
currency: isLastMile ? (values.currency ?? "ETB") : "USD",
|
||||||
trigger: isSurcharge ? values.trigger : "ALWAYS",
|
trigger: isSurcharge ? values.trigger : "ALWAYS",
|
||||||
|
...(isLastMile
|
||||||
|
? {
|
||||||
|
// Empty "To km" means an open-ended band — send null so an
|
||||||
|
// edit can clear a previously-set ceiling. On create the tier
|
||||||
|
// spread below overrides the band fields per tier.
|
||||||
|
maxKm: values.maxKm ?? null,
|
||||||
|
...(lastMileMode === "BULK"
|
||||||
|
? { rateUnit: "PER_TON_KM", containerTypeId: undefined }
|
||||||
|
: { rateUnit: "PER_KM" }),
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
};
|
};
|
||||||
// Editing a LIVE rate files a change request — the rate keeps charging
|
// Editing a LIVE rate files a change request — the rate keeps charging
|
||||||
// its current value until an approver applies it. DRAFT rates fall
|
// its current value until an approver applies it. DRAFT rates fall
|
||||||
@@ -604,6 +640,31 @@ const RuleEngineResourcePage = () => {
|
|||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Container-mode create: the tier list becomes one rate row per tier,
|
||||||
|
// created sequentially so an overlap/duplicate rejection stops the batch
|
||||||
|
// with its own toast instead of half-failing in parallel.
|
||||||
|
const tiers = (
|
||||||
|
payload as {
|
||||||
|
tiers?: Array<{ minKm: number; maxKm: number | null; rateValue: number }>;
|
||||||
|
}
|
||||||
|
).tiers;
|
||||||
|
if (!editing?.id && Array.isArray(tiers)) {
|
||||||
|
const { tiers: _omitted, ...base } = payload as Record<string, unknown>;
|
||||||
|
void _omitted;
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
for (const tier of tiers) {
|
||||||
|
await create.mutateAsync({ ...base, ...tier });
|
||||||
|
}
|
||||||
|
setFormOpen(false);
|
||||||
|
setEditing(null);
|
||||||
|
} catch {
|
||||||
|
// The create mutation already toasted the failure; keep the dialog
|
||||||
|
// open so the admin can fix the tier set and retry.
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return;
|
||||||
|
}
|
||||||
} else if (isPriorityRules) {
|
} else if (isPriorityRules) {
|
||||||
// Label is required by the backend but hidden in the UI for now.
|
// Label is required by the backend but hidden in the UI for now.
|
||||||
payload = { ...values, label: String(Date.now()) };
|
payload = { ...values, label: String(Date.now()) };
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ export type ColumnFormat =
|
|||||||
| "entityLabel"
|
| "entityLabel"
|
||||||
| "rateLabel";
|
| "rateLabel";
|
||||||
|
|
||||||
export type FormFieldType = "text" | "number" | "boolean" | "date" | "email" | "select" | "multiselect" | "textarea" | "radio";
|
export type FormFieldType = "text" | "number" | "boolean" | "date" | "email" | "select" | "multiselect" | "textarea" | "radio" | "tierList";
|
||||||
|
|
||||||
export interface ResourceColumn {
|
export interface ResourceColumn {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -70,6 +70,8 @@ export interface FormFieldDef {
|
|||||||
* relation list (`wagonTypeIds` read from `record.wagonTypes`).
|
* relation list (`wagonTypeIds` read from `record.wagonTypes`).
|
||||||
*/
|
*/
|
||||||
getInitialValue?: (record: Record<string, unknown>) => unknown;
|
getInitialValue?: (record: Record<string, unknown>) => unknown;
|
||||||
|
/** Pre-selected value on create (no record yet) — e.g. last-mile currency = ETB. */
|
||||||
|
defaultValue?: string;
|
||||||
/**
|
/**
|
||||||
* Fully derived field: its value is computed from the live form values on
|
* Fully derived field: its value is computed from the live form values on
|
||||||
* every render and the input is locked. Used for the priority-rule min
|
* every render and the input is locked. Used for the priority-rule min
|
||||||
@@ -267,8 +269,10 @@ const unitsForShape = (
|
|||||||
case "INTERCITY":
|
case "INTERCITY":
|
||||||
return ["PER_CONTAINER", "PER_TON", "PER_WAGON", "PER_KM"];
|
return ["PER_CONTAINER", "PER_TON", "PER_WAGON", "PER_KM"];
|
||||||
case "FIRST_MILE":
|
case "FIRST_MILE":
|
||||||
case "LAST_MILE":
|
|
||||||
return ["PER_CONTAINER", "PER_TON", "PER_KM", "FLAT"];
|
return ["PER_CONTAINER", "PER_TON", "PER_KM", "FLAT"];
|
||||||
|
case "LAST_MILE":
|
||||||
|
// PER_KM = container mode (distance-banded), PER_TON_KM = bulk mode.
|
||||||
|
return ["PER_KM", "PER_TON_KM", "PER_CONTAINER", "PER_TON", "FLAT"];
|
||||||
default:
|
default:
|
||||||
return ["FLAT"];
|
return ["FLAT"];
|
||||||
}
|
}
|
||||||
@@ -295,8 +299,8 @@ export const rateUnitOptions = (
|
|||||||
};
|
};
|
||||||
|
|
||||||
const CURRENCIES = [
|
const CURRENCIES = [
|
||||||
|
{ label: "ETB (Birr)", value: "ETB" },
|
||||||
{ label: "USD", value: "USD" },
|
{ label: "USD", value: "USD" },
|
||||||
{ label: "ETB", value: "ETB" },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const PRIORITY_CONFIG_TYPES = [
|
const PRIORITY_CONFIG_TYPES = [
|
||||||
@@ -846,6 +850,9 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
|||||||
},
|
},
|
||||||
{ id: "rateValue", header: "Value", accessorKey: "rateValue", format: "currency" },
|
{ id: "rateValue", header: "Value", accessorKey: "rateValue", format: "currency" },
|
||||||
{ id: "rateUnit", header: "Unit", accessorKey: "rateUnit" },
|
{ id: "rateUnit", header: "Unit", accessorKey: "rateUnit" },
|
||||||
|
// Container last-mile distance bands; blank for every other rate shape.
|
||||||
|
{ id: "minKm", header: "From km", accessorKey: "minKm", format: "number" },
|
||||||
|
{ id: "maxKm", header: "To km", accessorKey: "maxKm", format: "number" },
|
||||||
{ id: "status", header: "Status", accessorKey: "status", format: "rateStatus" },
|
{ id: "status", header: "Status", accessorKey: "status", format: "rateStatus" },
|
||||||
],
|
],
|
||||||
formFields: [
|
formFields: [
|
||||||
@@ -958,6 +965,83 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
|||||||
getInitialValue: (record) =>
|
getInitialValue: (record) =>
|
||||||
record.rateType === "INTERCITY_BULK" ? "BULK" : "CONTAINER",
|
record.rateType === "INTERCITY_BULK" ? "BULK" : "CONTAINER",
|
||||||
},
|
},
|
||||||
|
// ── Last mile — two calculation modes ─────────────────────────────────
|
||||||
|
// Bulk bills per ton per km (price = tons × km × rate); Container bills
|
||||||
|
// per km, banded by distance range with one rate row per container type
|
||||||
|
// per band (price = km × rate × quantity).
|
||||||
|
{
|
||||||
|
name: "lastMileMode",
|
||||||
|
label: "Calculation mode",
|
||||||
|
type: "select",
|
||||||
|
required: true,
|
||||||
|
options: [
|
||||||
|
{ label: "Bulk (per ton per km)", value: "BULK" },
|
||||||
|
{ label: "Container (per km, distance-banded)", value: "CONTAINER" },
|
||||||
|
],
|
||||||
|
description:
|
||||||
|
"Bulk: price = tons × km × rate. Container: price = km × band rate × quantity, one rate per container type per distance band.",
|
||||||
|
showWhen: { field: "appliesTo", equals: ["LAST_MILE"] },
|
||||||
|
// Not a stored column: the mode is recorded in the unit the API keeps.
|
||||||
|
getInitialValue: (record) =>
|
||||||
|
record.rateUnit === "PER_TON_KM" ? "BULK" : "CONTAINER",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "containerTypeId",
|
||||||
|
label: "Container type",
|
||||||
|
type: "select",
|
||||||
|
required: true,
|
||||||
|
placeholder: "Which container type this band prices",
|
||||||
|
description: "20ft and 40ft price differently — one rate per type per band.",
|
||||||
|
showIf: (v) =>
|
||||||
|
v.appliesTo === "LAST_MILE" && v.lastMileMode === "CONTAINER",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "minKm",
|
||||||
|
label: "From km",
|
||||||
|
type: "number",
|
||||||
|
required: true,
|
||||||
|
placeholder: "0",
|
||||||
|
description: "Band start (inclusive). Use 0 for the first band.",
|
||||||
|
showIf: (v) =>
|
||||||
|
v.appliesTo === "LAST_MILE" &&
|
||||||
|
(v.lastMileMode === "CONTAINER" || v.lastMileMode === "BULK"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "maxKm",
|
||||||
|
label: "To km",
|
||||||
|
type: "number",
|
||||||
|
optional: true,
|
||||||
|
placeholder: "Leave empty for no upper limit",
|
||||||
|
description: "Band end (exclusive) — a 0–30 band covers up to but not including 30 km.",
|
||||||
|
showIf: (v) =>
|
||||||
|
v.appliesTo === "LAST_MILE" &&
|
||||||
|
(v.lastMileMode === "CONTAINER" || v.lastMileMode === "BULK"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "currency",
|
||||||
|
label: "Currency",
|
||||||
|
type: "select",
|
||||||
|
required: true,
|
||||||
|
options: CURRENCIES,
|
||||||
|
showWhen: { field: "appliesTo", equals: ["LAST_MILE"] },
|
||||||
|
// Birr is the norm for domestic trucking; USD stays selectable.
|
||||||
|
defaultValue: "ETB",
|
||||||
|
getInitialValue: (record) => String(record.currency ?? "ETB"),
|
||||||
|
},
|
||||||
|
// ── Distance tiers (create only — the page swaps this for the single
|
||||||
|
// From/To/value fields when editing an existing band row). Each tier
|
||||||
|
// becomes its own rate row, so every band keeps edit/delete/approval. ──
|
||||||
|
{
|
||||||
|
name: "tiers",
|
||||||
|
label: "Distance tiers",
|
||||||
|
type: "tierList",
|
||||||
|
required: true,
|
||||||
|
description:
|
||||||
|
"One rate per distance range — the rate value is per km (container mode) or per ton per km (bulk mode). To km is exclusive (0–30 then 30+); leave the last tier's To km empty for no upper limit.",
|
||||||
|
showIf: (v) =>
|
||||||
|
v.appliesTo === "LAST_MILE" &&
|
||||||
|
(v.lastMileMode === "CONTAINER" || v.lastMileMode === "BULK"),
|
||||||
|
},
|
||||||
// ── Container type — Container freight, container-kind intercity, and
|
// ── Container type — Container freight, container-kind intercity, and
|
||||||
// the empty-container return surcharge (20ft vs 40ft price differently) ─
|
// the empty-container return surcharge (20ft vs 40ft price differently) ─
|
||||||
{
|
{
|
||||||
@@ -1002,10 +1086,29 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
|||||||
placeholder: "Where the leg ends",
|
placeholder: "Where the leg ends",
|
||||||
showIf: isRouteScopedRate,
|
showIf: isRouteScopedRate,
|
||||||
},
|
},
|
||||||
{ name: "rateValue", label: "Rate value", type: "number", required: true, suffix: "USD" },
|
{
|
||||||
|
name: "rateValue",
|
||||||
|
label: "Rate value",
|
||||||
|
type: "number",
|
||||||
|
required: true,
|
||||||
|
suffix: "USD",
|
||||||
|
showIf: (v) => v.appliesTo !== "LAST_MILE",
|
||||||
|
},
|
||||||
|
// Last-mile rates carry their own currency (birr or dollar) and the
|
||||||
|
// value is a per-km / per-ton·km price, so no hardcoded USD suffix.
|
||||||
|
{
|
||||||
|
name: "rateValue",
|
||||||
|
label: "Rate value",
|
||||||
|
type: "number",
|
||||||
|
required: true,
|
||||||
|
description:
|
||||||
|
"Container mode: price per km for this band. Bulk mode: price per ton per km.",
|
||||||
|
showWhen: { field: "appliesTo", equals: ["LAST_MILE"] },
|
||||||
|
},
|
||||||
// Unit choices are driven by the rate shape (appliesTo + trigger). Overweight
|
// Unit choices are driven by the rate shape (appliesTo + trigger). Overweight
|
||||||
// is always per excess ton, so the unit field is hidden for it — the API
|
// is always per excess ton, so the unit field is hidden for it — the API
|
||||||
// forces PER_TON regardless.
|
// forces PER_TON regardless. Last mile derives its unit from the
|
||||||
|
// calculation mode instead.
|
||||||
{
|
{
|
||||||
name: "rateUnit",
|
name: "rateUnit",
|
||||||
label: "Rate unit",
|
label: "Rate unit",
|
||||||
@@ -1014,7 +1117,9 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
|||||||
optionsFromValues: rateUnitOptions,
|
optionsFromValues: rateUnitOptions,
|
||||||
description:
|
description:
|
||||||
"Weighting basis — options depend on what the rate applies to, and for bulk on how the picked commodity is counted (per ton or per item).",
|
"Weighting basis — options depend on what the rate applies to, and for bulk on how the picked commodity is counted (per ton or per item).",
|
||||||
hideWhen: { field: "trigger", equals: ["OVERWEIGHT"] },
|
showIf: (v) =>
|
||||||
|
String(v.trigger ?? "") !== "OVERWEIGHT" &&
|
||||||
|
String(v.appliesTo ?? "") !== "LAST_MILE",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -163,6 +163,11 @@ import {
|
|||||||
type SaveLocomotivePayload,
|
type SaveLocomotivePayload,
|
||||||
} from "./locomotives.service";
|
} from "./locomotives.service";
|
||||||
import { overviewService } from "./overview.service";
|
import { overviewService } from "./overview.service";
|
||||||
|
import {
|
||||||
|
auditService,
|
||||||
|
type AuditLogListFilter,
|
||||||
|
type PaginatedAuditLogs,
|
||||||
|
} from "./audit.service";
|
||||||
import { reportsService } from "./reports.service";
|
import { reportsService } from "./reports.service";
|
||||||
import type { ReportQueryInput, ReportResult } from "@/types/reports";
|
import type { ReportQueryInput, ReportResult } from "@/types/reports";
|
||||||
import {
|
import {
|
||||||
@@ -2136,6 +2141,15 @@ export const api = {
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
|
||||||
|
audit: {
|
||||||
|
list: endpoint<{ filter?: AuditLogListFilter }, PaginatedAuditLogs>(
|
||||||
|
"audit",
|
||||||
|
"list",
|
||||||
|
({ filter }) => auditService.list(filter),
|
||||||
|
({ filter }) => ["audit", "list", filter ?? {}],
|
||||||
|
),
|
||||||
|
},
|
||||||
|
|
||||||
signatures: {
|
signatures: {
|
||||||
mySignature: endpoint<void, SavedSignature | null>(
|
mySignature: endpoint<void, SavedSignature | null>(
|
||||||
"me",
|
"me",
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { api as client } from "../auth/http";
|
||||||
|
import { unwrap } from "@/utils/endpoint";
|
||||||
|
import { URL_CONSTANTS } from "@/constants/URLS";
|
||||||
|
|
||||||
|
const A = URL_CONSTANTS.AUDIT;
|
||||||
|
|
||||||
|
// Shape from @tria-plc/auditlog's AuditLogCommandController — see
|
||||||
|
// local-packages/FRONTEND_GUIDE.md.
|
||||||
|
export type AuditQueryMethod =
|
||||||
|
| "INSERT"
|
||||||
|
| "UPDATE"
|
||||||
|
| "DELETE"
|
||||||
|
| "INSERT_CHILD"
|
||||||
|
| "DELETE_CHILD";
|
||||||
|
|
||||||
|
export interface AuditFieldChange {
|
||||||
|
field: string;
|
||||||
|
from: unknown;
|
||||||
|
to: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
// IAM entities (users, orgs, positions, ...) name themselves bilingually —
|
||||||
|
// see edr-org.seeder.ts. Any `name`/`title` field lifted from a raw audited
|
||||||
|
// entity (auditLog.user, payload) can come back as either a plain string or
|
||||||
|
// this shape; both `name` fields below reflect that.
|
||||||
|
export type LocalizedText = string | { am?: string; en?: string };
|
||||||
|
|
||||||
|
// The vendored interceptor's own broken template produces a plain string
|
||||||
|
// ("undefined undefined") when no user was attached at all (unauthenticated/
|
||||||
|
// customer flows) — that's the non-bilingual string case for `name` here.
|
||||||
|
export interface AuditUser {
|
||||||
|
id?: string;
|
||||||
|
name?: LocalizedText;
|
||||||
|
organizationId?: string;
|
||||||
|
organizationName?: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuditLogRow {
|
||||||
|
id?: string;
|
||||||
|
createdAt: string;
|
||||||
|
deletedAt?: string | null;
|
||||||
|
entityName: string;
|
||||||
|
queryMethod: AuditQueryMethod;
|
||||||
|
changes?: AuditFieldChange[] | null;
|
||||||
|
payload?: { name?: LocalizedText; title?: LocalizedText; id?: string } | null;
|
||||||
|
auditLog?: { id?: string; user?: AuditUser | null };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuditLogListFilter {
|
||||||
|
skip?: number;
|
||||||
|
take?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PaginatedAuditLogs {
|
||||||
|
items: AuditLogRow[];
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const auditService = {
|
||||||
|
list: async (filter?: AuditLogListFilter): Promise<PaginatedAuditLogs> => {
|
||||||
|
const params: Record<string, number | undefined> = {
|
||||||
|
skip: filter?.skip,
|
||||||
|
take: filter?.take,
|
||||||
|
};
|
||||||
|
const response = await client.get<PaginatedAuditLogs>(A.LOGS, { params });
|
||||||
|
const data = unwrap(response.data) as PaginatedAuditLogs;
|
||||||
|
return { items: data.items ?? [], count: data.count ?? 0 };
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { api } from '../auth/http';
|
||||||
|
import { URL_CONSTANTS } from '@/constants/URLS';
|
||||||
|
|
||||||
|
export const LAST_MILE_REQUEST_STATUSES = [
|
||||||
|
'AWAITING_CONFIRMATION',
|
||||||
|
'SUBMITTED',
|
||||||
|
'APPROVED',
|
||||||
|
'REJECTED',
|
||||||
|
] as const;
|
||||||
|
export type LastMileRequestStatus = (typeof LAST_MILE_REQUEST_STATUSES)[number];
|
||||||
|
|
||||||
|
export interface LastMileRequest {
|
||||||
|
id: string;
|
||||||
|
bookingId: string;
|
||||||
|
booking?: {
|
||||||
|
id: string;
|
||||||
|
reference?: string;
|
||||||
|
companyId?: string;
|
||||||
|
company?: { id: string; name?: string } | null;
|
||||||
|
} | null;
|
||||||
|
trainScheduleId: string;
|
||||||
|
status: LastMileRequestStatus;
|
||||||
|
requestedContainerNumbers?: string[] | null;
|
||||||
|
reminderSentAt?: string | null;
|
||||||
|
submittedByUserId?: string | null;
|
||||||
|
submittedAt?: string | null;
|
||||||
|
reviewedByStaffId?: string | null;
|
||||||
|
reviewedAt?: string | null;
|
||||||
|
rejectionReason?: string | null;
|
||||||
|
resultingLastMileId?: string | null;
|
||||||
|
requestedDeliveryDate?: string | null;
|
||||||
|
customerSignedAt?: string | null;
|
||||||
|
signerDisplayName?: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LastMileRequestListResponse {
|
||||||
|
data: LastMileRequest[];
|
||||||
|
meta: { total: number; page: number; pageSize: number; totalPages: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Rule-based estimate for the approve dialog — all nulls when no rule covers the job. */
|
||||||
|
export interface LastMilePriceEstimate {
|
||||||
|
estimatedKm: number | null;
|
||||||
|
mode: 'BULK' | 'CONTAINER' | null;
|
||||||
|
currency: string | null;
|
||||||
|
total: number | null;
|
||||||
|
lines: Array<{ description: string; amount: number }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LMR = URL_CONSTANTS.LAST_MILE_REQUESTS;
|
||||||
|
|
||||||
|
export const lastMileRequestsService = {
|
||||||
|
list: (params?: { status?: LastMileRequestStatus; bookingId?: string; page?: number; pageSize?: number }) =>
|
||||||
|
api.get<LastMileRequestListResponse>(LMR.BASE, { params }),
|
||||||
|
getById: (id: string) => api.get<LastMileRequest>(LMR.BY_ID(id)),
|
||||||
|
freeTruckCount: () => api.get<{ count: number }>(LMR.FREE_TRUCK_COUNT),
|
||||||
|
priceEstimate: (id: string) => api.get<LastMilePriceEstimate>(LMR.PRICE_ESTIMATE(id)),
|
||||||
|
approve: (id: string, advanceAmount: number) =>
|
||||||
|
api.post<LastMileRequest>(LMR.APPROVE(id), { advanceAmount }),
|
||||||
|
reject: (id: string, reason: string) =>
|
||||||
|
api.post<LastMileRequest>(LMR.REJECT(id), { reason }),
|
||||||
|
contractDocument: (id: string) =>
|
||||||
|
api.get<Blob>(LMR.CONTRACT_DOCUMENT(id), { responseType: 'blob' }),
|
||||||
|
};
|
||||||
@@ -2,6 +2,7 @@ import path from "node:path";
|
|||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import { createRequire } from "node:module";
|
import { createRequire } from "node:module";
|
||||||
|
|
||||||
|
import { loadEnv } from "vite";
|
||||||
import { defineConfig } from "vitest/config";
|
import { defineConfig } from "vitest/config";
|
||||||
import react from "@vitejs/plugin-react";
|
import react from "@vitejs/plugin-react";
|
||||||
import tailwindcss from "@tailwindcss/vite";
|
import tailwindcss from "@tailwindcss/vite";
|
||||||
@@ -10,7 +11,9 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|||||||
const require = createRequire(import.meta.url);
|
const require = createRequire(import.meta.url);
|
||||||
const streamBrowserifyPath = require.resolve("stream-browserify");
|
const streamBrowserifyPath = require.resolve("stream-browserify");
|
||||||
|
|
||||||
export default defineConfig(() => {
|
export default defineConfig(({ mode }) => {
|
||||||
|
const env = loadEnv(mode, __dirname, "");
|
||||||
|
|
||||||
return {
|
return {
|
||||||
plugins: [react(), tailwindcss()],
|
plugins: [react(), tailwindcss()],
|
||||||
resolve: {
|
resolve: {
|
||||||
@@ -31,7 +34,7 @@ export default defineConfig(() => {
|
|||||||
dedupe: ["react", "react-dom", "@mantine/core", "@mantine/hooks"],
|
dedupe: ["react", "react-dom", "@mantine/core", "@mantine/hooks"],
|
||||||
},
|
},
|
||||||
server: {
|
server: {
|
||||||
port: 5183,
|
port: Number(env.PORT) || 5283,
|
||||||
host: "0.0.0.0",
|
host: "0.0.0.0",
|
||||||
},
|
},
|
||||||
test: {
|
test: {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite --port 3000 --clearScreen false",
|
"dev": "vite --clearScreen false",
|
||||||
"build": "tsc -b && vite build",
|
"build": "tsc -b && vite build",
|
||||||
"preview": "vite preview --port 5173",
|
"preview": "vite preview --port 5173",
|
||||||
"lint": "eslint src",
|
"lint": "eslint src",
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useDisclosure } from "@mantine/hooks";
|
|||||||
import {
|
import {
|
||||||
Home,
|
Home,
|
||||||
Layers,
|
Layers,
|
||||||
|
LifeBuoy,
|
||||||
Loader2,
|
Loader2,
|
||||||
// MapPin,
|
// MapPin,
|
||||||
Package,
|
Package,
|
||||||
@@ -46,6 +47,8 @@ import BookingContractPage from "./pages/bookings/BookingContractPage";
|
|||||||
import BookingDetailPage from "./pages/bookings/BookingDetailPage";
|
import BookingDetailPage from "./pages/bookings/BookingDetailPage";
|
||||||
import BookingsListPage from "./pages/bookings/BookingsListPage";
|
import BookingsListPage from "./pages/bookings/BookingsListPage";
|
||||||
import EditBookingPage from "./pages/bookings/EditBookingPage";
|
import EditBookingPage from "./pages/bookings/EditBookingPage";
|
||||||
|
import LastMileConfirmPage from "./pages/bookings/last-mile-confirm/LastMileConfirmPage";
|
||||||
|
import LastMileContractPage from "./pages/bookings/last-mile-contract/LastMileContractPage";
|
||||||
import ContractDetailPage from "./pages/contracts/ContractDetailPage";
|
import ContractDetailPage from "./pages/contracts/ContractDetailPage";
|
||||||
import ContractViewPage from "./pages/contracts/ContractViewPage";
|
import ContractViewPage from "./pages/contracts/ContractViewPage";
|
||||||
import ContractsList from "./pages/contracts/ContractsList";
|
import ContractsList from "./pages/contracts/ContractsList";
|
||||||
@@ -56,6 +59,10 @@ import CheckPaymentPage from "./pages/payments/CheckPaymentPage";
|
|||||||
import PaymentFailurePage from "./pages/payments/PaymentFailurePage";
|
import PaymentFailurePage from "./pages/payments/PaymentFailurePage";
|
||||||
import FaydaCallbackPage from "./pages/FaydaCallbackPage";
|
import FaydaCallbackPage from "./pages/FaydaCallbackPage";
|
||||||
import PaymentSuccessPage from "./pages/payments/PaymentSuccessPage";
|
import PaymentSuccessPage from "./pages/payments/PaymentSuccessPage";
|
||||||
|
import FaqPage from "./pages/support/FaqPage";
|
||||||
|
import HelpPage from "./pages/support/HelpPage";
|
||||||
|
import PrivacyPolicyPage from "./pages/support/PrivacyPolicyPage";
|
||||||
|
import TermsPage from "./pages/support/TermsPage";
|
||||||
import TrackingPage from "./pages/tracking/TrackingPage";
|
import TrackingPage from "./pages/tracking/TrackingPage";
|
||||||
|
|
||||||
function FullScreenSpinner() {
|
function FullScreenSpinner() {
|
||||||
@@ -215,6 +222,12 @@ const sidebarItems: SidebarItem[] = [
|
|||||||
href: "/settings",
|
href: "/settings",
|
||||||
icon: <Settings size={18} />,
|
icon: <Settings size={18} />,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
section: "Account",
|
||||||
|
label: "Help & Support",
|
||||||
|
href: "/help",
|
||||||
|
icon: <LifeBuoy size={18} />,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const App = () => {
|
const App = () => {
|
||||||
@@ -271,6 +284,14 @@ const App = () => {
|
|||||||
<Route path="/payment/success" element={<PaymentSuccessPage />} />
|
<Route path="/payment/success" element={<PaymentSuccessPage />} />
|
||||||
<Route path="/payment/failure" element={<PaymentFailurePage />} />
|
<Route path="/payment/failure" element={<PaymentFailurePage />} />
|
||||||
|
|
||||||
|
{/* Help and legal pages. Public on purpose: the auth screens link to
|
||||||
|
them before a session exists, so they carry their own chrome rather
|
||||||
|
than sitting inside the authenticated app layout. */}
|
||||||
|
<Route path="/help" element={<HelpPage />} />
|
||||||
|
<Route path="/faq" element={<FaqPage />} />
|
||||||
|
<Route path="/privacy" element={<PrivacyPolicyPage />} />
|
||||||
|
<Route path="/terms" element={<TermsPage />} />
|
||||||
|
|
||||||
{/* Auth pages — inaccessible once logged in */}
|
{/* Auth pages — inaccessible once logged in */}
|
||||||
<Route element={<RedirectIfAuthed />}>
|
<Route element={<RedirectIfAuthed />}>
|
||||||
<Route path="/login" element={<LoginPage />} />
|
<Route path="/login" element={<LoginPage />} />
|
||||||
@@ -318,6 +339,14 @@ const App = () => {
|
|||||||
/>
|
/>
|
||||||
<Route path="/bookings/:id/edit" element={<EditBookingPage />} />
|
<Route path="/bookings/:id/edit" element={<EditBookingPage />} />
|
||||||
<Route path="/bookings/:id" element={<BookingDetailPage />} />
|
<Route path="/bookings/:id" element={<BookingDetailPage />} />
|
||||||
|
<Route
|
||||||
|
path="/bookings/:id/last-mile-confirm"
|
||||||
|
element={<LastMileConfirmPage />}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/bookings/:id/last-mile-contract"
|
||||||
|
element={<LastMileContractPage />}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/bookings/:id/contract"
|
path="/bookings/:id/contract"
|
||||||
element={<BookingContractPage />}
|
element={<BookingContractPage />}
|
||||||
|
|||||||
@@ -129,19 +129,19 @@ const FormFooter = () => (
|
|||||||
<span className="shrink-0">© 2026 EDR Freight</span>
|
<span className="shrink-0">© 2026 EDR Freight</span>
|
||||||
<div className="flex flex-wrap items-center justify-center gap-3 sm:justify-end sm:gap-6">
|
<div className="flex flex-wrap items-center justify-center gap-3 sm:justify-end sm:gap-6">
|
||||||
<Link
|
<Link
|
||||||
to="#"
|
to="/terms"
|
||||||
className="font-semibold text-gray-700 transition-colors hover:text-primary"
|
className="font-semibold text-gray-700 transition-colors hover:text-primary"
|
||||||
>
|
>
|
||||||
Terms & Conditions
|
Terms & Conditions
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
to="#"
|
to="/privacy"
|
||||||
className="font-semibold text-gray-700 transition-colors hover:text-primary"
|
className="font-semibold text-gray-700 transition-colors hover:text-primary"
|
||||||
>
|
>
|
||||||
Privacy Policy
|
Privacy Policy
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
to="#"
|
to="/help"
|
||||||
className="font-semibold text-gray-700 transition-colors hover:text-primary"
|
className="font-semibold text-gray-700 transition-colors hover:text-primary"
|
||||||
>
|
>
|
||||||
Help & Support
|
Help & Support
|
||||||
|
|||||||
@@ -26,9 +26,22 @@ interface ETradeInfoProps {
|
|||||||
onStatusChange?: (status: ETradeStatus) => void;
|
onStatusChange?: (status: ETradeStatus) => void;
|
||||||
/** Called when the TIN changes away from the last fetched value — clear whatever it filled in. */
|
/** Called when the TIN changes away from the last fetched value — clear whatever it filled in. */
|
||||||
onReset?: () => void;
|
onReset?: () => void;
|
||||||
|
/**
|
||||||
|
* This TIN already passed eTrade in an earlier session (the saved profile
|
||||||
|
* carries its registration details), so adopt it on arrival instead of
|
||||||
|
* re-querying. Rehydration lands the TIN after the first render, which used
|
||||||
|
* to look exactly like the customer typing a new one: every reopen fired a
|
||||||
|
* live lookup that could fail on an outage, and re-marked eTrade's fields as
|
||||||
|
* freshly verified so they were resubmitted on the next save. "Get Data"
|
||||||
|
* stays available for a deliberate re-verify.
|
||||||
|
*/
|
||||||
|
alreadyVerified?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const isValidTin = (tin: string) => tin.length === 10;
|
// Digits, not just length: a 10-character non-numeric TIN used to fire a lookup
|
||||||
|
// that could only fail, and the failure was then reported as "this TIN isn't
|
||||||
|
// registered with eTrade" instead of "that isn't a TIN".
|
||||||
|
const isValidTin = (tin: string) => /^\d{10}$/.test(tin);
|
||||||
|
|
||||||
export default function ETradeInfo({
|
export default function ETradeInfo({
|
||||||
tin,
|
tin,
|
||||||
@@ -37,6 +50,7 @@ export default function ETradeInfo({
|
|||||||
onDataLoaded,
|
onDataLoaded,
|
||||||
onStatusChange,
|
onStatusChange,
|
||||||
onReset,
|
onReset,
|
||||||
|
alreadyVerified,
|
||||||
}: ETradeInfoProps) {
|
}: ETradeInfoProps) {
|
||||||
const mutation = useETradeData();
|
const mutation = useETradeData();
|
||||||
const isLoading = mutation.isPending;
|
const isLoading = mutation.isPending;
|
||||||
@@ -63,6 +77,13 @@ export default function ETradeInfo({
|
|||||||
// doesn't refire the lookup the moment this mounts.
|
// doesn't refire the lookup the moment this mounts.
|
||||||
const lastFetchedTin = useRef<string | null>(tin || null);
|
const lastFetchedTin = useRef<string | null>(tin || null);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// A rehydrated TIN that eTrade already accepted: adopt it silently. Doing
|
||||||
|
// this before the change-detection below also keeps `onReset` from firing,
|
||||||
|
// which would wipe the very registration details that prove it passed.
|
||||||
|
if (alreadyVerified && lastFetchedTin.current === null && isValidTin(tin)) {
|
||||||
|
lastFetchedTin.current = tin;
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (tin !== lastFetchedTin.current) {
|
if (tin !== lastFetchedTin.current) {
|
||||||
// TIN moved away from whatever we last fetched — that result (verified
|
// TIN moved away from whatever we last fetched — that result (verified
|
||||||
// data, "taken", or an error) no longer describes this TIN. Drop it so
|
// data, "taken", or an error) no longer describes this TIN. Drop it so
|
||||||
@@ -82,12 +103,17 @@ export default function ETradeInfo({
|
|||||||
|
|
||||||
const apiError =
|
const apiError =
|
||||||
mutation.isError && mutation.error ? extractApiError(mutation.error) : null;
|
mutation.isError && mutation.error ? extractApiError(mutation.error) : null;
|
||||||
// A 400 here means eTrade simply has no record for this TIN.
|
// A 400 here usually means eTrade has no record for this TIN — but the API
|
||||||
const notFound = apiError?.statusCode === 400;
|
// also wraps its own transport failures as a 400 ("Failed to fetch company
|
||||||
|
// info from eTrade: …"), and reporting an outage as "this TIN isn't
|
||||||
|
// registered" sends the customer off to re-check a number that was fine.
|
||||||
|
const unreachable = /failed to fetch/i.test(apiError?.message ?? "");
|
||||||
|
const notFound = apiError?.statusCode === 400 && !unreachable;
|
||||||
const errorMessage =
|
const errorMessage =
|
||||||
apiError && !notFound
|
apiError && !notFound
|
||||||
? apiError.message ||
|
? unreachable || !apiError.message
|
||||||
"We couldn't reach eTrade to fetch your company information. Please try again."
|
? "We couldn't reach eTrade to fetch your company information. Please try again in a moment."
|
||||||
|
: apiError.message
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
const status: ETradeStatus = isLoading
|
const status: ETradeStatus = isLoading
|
||||||
|
|||||||
@@ -217,4 +217,12 @@ export const URL_CONSTANTS = {
|
|||||||
RECEIPT: (id: string) => `/api/warehouse-fee-invoices/${id}/receipt`,
|
RECEIPT: (id: string) => `/api/warehouse-fee-invoices/${id}/receipt`,
|
||||||
PAY_ONLINE: (id: string) => `/api/warehouse-fee-invoices/${id}/pay-online`,
|
PAY_ONLINE: (id: string) => `/api/warehouse-fee-invoices/${id}/pay-online`,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
LAST_MILE_REQUESTS: {
|
||||||
|
BY_ID: (id: string) => `/last-mile-requests/${id}`,
|
||||||
|
SUBMIT: (id: string) => `/last-mile-requests/${id}/submit`,
|
||||||
|
CONTRACT_VIEW: (id: string) => `/last-mile-requests/${id}/contract/view`,
|
||||||
|
CONTRACT_DOCUMENT: (id: string) => `/last-mile-requests/${id}/contract/document`,
|
||||||
|
CONTRACT_SIGN: (id: string) => `/last-mile-requests/${id}/contract/sign`,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -584,7 +584,35 @@ export default function EDRFreightLandingPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>© 2026 EDR Freight. All rights reserved.</div>
|
<div className="flex flex-col items-center gap-4 md:items-end">
|
||||||
|
<nav className="flex flex-wrap items-center justify-center gap-x-6 gap-y-2">
|
||||||
|
<Link
|
||||||
|
to="/help"
|
||||||
|
className="font-semibold text-foreground transition-colors hover:text-primary"
|
||||||
|
>
|
||||||
|
Help & Support
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
to="/faq"
|
||||||
|
className="font-semibold text-foreground transition-colors hover:text-primary"
|
||||||
|
>
|
||||||
|
FAQ
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
to="/privacy"
|
||||||
|
className="font-semibold text-foreground transition-colors hover:text-primary"
|
||||||
|
>
|
||||||
|
Privacy Policy
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
to="/terms"
|
||||||
|
className="font-semibold text-foreground transition-colors hover:text-primary"
|
||||||
|
>
|
||||||
|
Terms of Service
|
||||||
|
</Link>
|
||||||
|
</nav>
|
||||||
|
<div>© 2026 EDR Freight. All rights reserved.</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -38,6 +38,10 @@ import {
|
|||||||
} from "./companyProfileForm/schema";
|
} from "./companyProfileForm/schema";
|
||||||
import {
|
import {
|
||||||
buildPayload,
|
buildPayload,
|
||||||
|
firstPresent,
|
||||||
|
firstValidEmail,
|
||||||
|
firstValidPhone,
|
||||||
|
normalizeIdentityPhones,
|
||||||
stepPayload,
|
stepPayload,
|
||||||
toFormValues,
|
toFormValues,
|
||||||
} from "./companyProfileForm/helpers";
|
} from "./companyProfileForm/helpers";
|
||||||
@@ -45,6 +49,7 @@ import FaydaVerifyPanel from "@/components/FaydaVerifyPanel";
|
|||||||
import { verifaydaService } from "@/services/verifayda.service";
|
import { verifaydaService } from "@/services/verifayda.service";
|
||||||
import type { CompanyIdentityState } from "@/services/verifayda.service";
|
import type { CompanyIdentityState } from "@/services/verifayda.service";
|
||||||
import { LinkCheckboxCard } from "./companyProfileForm/LinkCheckboxCard";
|
import { LinkCheckboxCard } from "./companyProfileForm/LinkCheckboxCard";
|
||||||
|
import { ReadOnlyField } from "./companyProfileForm/ReadOnlyField";
|
||||||
import ETradeCompanyCard from "./companyProfileForm/ETradeCompanyCard";
|
import ETradeCompanyCard from "./companyProfileForm/ETradeCompanyCard";
|
||||||
import StepSection from "./companyProfileForm/StepSection";
|
import StepSection from "./companyProfileForm/StepSection";
|
||||||
|
|
||||||
@@ -67,7 +72,7 @@ export default function CompanyProfileForm({
|
|||||||
submitError,
|
submitError,
|
||||||
uploadedDocumentKeys,
|
uploadedDocumentKeys,
|
||||||
onUploadDocuments,
|
onUploadDocuments,
|
||||||
identity,
|
identity: rawIdentity,
|
||||||
onIdentityChange,
|
onIdentityChange,
|
||||||
}: {
|
}: {
|
||||||
documentSettingCode: string;
|
documentSettingCode: string;
|
||||||
@@ -115,6 +120,15 @@ export default function CompanyProfileForm({
|
|||||||
*/
|
*/
|
||||||
onIdentityChange?: () => void;
|
onIdentityChange?: () => void;
|
||||||
}) {
|
}) {
|
||||||
|
// A Fayda claim carries the phone as the national registry holds it, which is
|
||||||
|
// often a local number the form's E.164 validation (and the API's
|
||||||
|
// `@IsValidPhone()`) would reject — for a value the customer never typed and
|
||||||
|
// has no field to correct. Normalize once, here, so every read below is safe.
|
||||||
|
const identity = useMemo(
|
||||||
|
() => normalizeIdentityPhones(rawIdentity),
|
||||||
|
[rawIdentity],
|
||||||
|
);
|
||||||
|
|
||||||
const [step, setStep] = useState<CompanyStep>(initialStep ?? "company");
|
const [step, setStep] = useState<CompanyStep>(initialStep ?? "company");
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [saveError, setSaveError] = useState<string | null>(null);
|
const [saveError, setSaveError] = useState<string | null>(null);
|
||||||
@@ -188,15 +202,20 @@ export default function CompanyProfileForm({
|
|||||||
const {
|
const {
|
||||||
register,
|
register,
|
||||||
control,
|
control,
|
||||||
handleSubmit,
|
|
||||||
trigger,
|
trigger,
|
||||||
watch,
|
watch,
|
||||||
setValue,
|
setValue,
|
||||||
formState: { errors },
|
getValues,
|
||||||
|
formState: { errors, dirtyFields },
|
||||||
} = useForm<FormData>({
|
} = useForm<FormData>({
|
||||||
resolver: zodResolver(
|
resolver: zodResolver(
|
||||||
buildOnboardingSchema(identity?.passportRequired === true),
|
buildOnboardingSchema(identity?.passportRequired === true),
|
||||||
),
|
),
|
||||||
|
// `values` below re-seeds the form whenever the profile is refetched — and
|
||||||
|
// an in-page identity action (ticking "same as owner") refetches it. Without
|
||||||
|
// this, that reset silently throws away whatever the customer was part-way
|
||||||
|
// through typing on the current step.
|
||||||
|
resetOptions: { keepDirtyValues: true, keepErrors: true },
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
companyName: "",
|
companyName: "",
|
||||||
companyEmail: "",
|
companyEmail: "",
|
||||||
@@ -266,21 +285,28 @@ export default function CompanyProfileForm({
|
|||||||
phone: string;
|
phone: string;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
|
||||||
|
// `shouldDirty` is what marks the eTrade bundle as "re-verified this session";
|
||||||
|
// `stepPayload` sends those keys only when dirty, so an unchanged record is
|
||||||
|
// never echoed back to the API (which would make it re-query eTrade).
|
||||||
const handleETradeDataLoaded = (data: CompanyRegistrationData) => {
|
const handleETradeDataLoaded = (data: CompanyRegistrationData) => {
|
||||||
|
const dirty = { shouldDirty: true } as const;
|
||||||
if (data.companyName) {
|
if (data.companyName) {
|
||||||
setValue("companyName", data.companyName, { shouldValidate: true });
|
setValue("companyName", data.companyName, {
|
||||||
|
shouldValidate: true,
|
||||||
|
...dirty,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
setValue("licenceNumber", data.licenceNumber);
|
setValue("licenceNumber", data.licenceNumber, dirty);
|
||||||
setValue("statusDescription", data.statusDescription);
|
setValue("statusDescription", data.statusDescription, dirty);
|
||||||
setValue("dateRegistered", data.dateRegistered);
|
setValue("dateRegistered", data.dateRegistered, dirty);
|
||||||
setValue("renewedFrom", data.renewedFrom);
|
setValue("renewedFrom", data.renewedFrom, dirty);
|
||||||
setValue("renewalDate", data.renewalDate);
|
setValue("renewalDate", data.renewalDate, dirty);
|
||||||
setValue("renewedTo", data.renewedTo);
|
setValue("renewedTo", data.renewedTo, dirty);
|
||||||
setValue("region", data.region);
|
setValue("region", data.region, dirty);
|
||||||
setValue("zone", data.zone);
|
setValue("zone", data.zone, dirty);
|
||||||
setValue("woreda", data.woreda);
|
setValue("woreda", data.woreda, dirty);
|
||||||
setValue("kebele", data.kebele);
|
setValue("kebele", data.kebele, dirty);
|
||||||
setValue("houseNo", data.houseNo);
|
setValue("houseNo", data.houseNo, dirty);
|
||||||
// companyAddress is composed reactively from the address fields below, so
|
// companyAddress is composed reactively from the address fields below, so
|
||||||
// setting region/zone/woreda/kebele/houseNo above is enough — no need to
|
// setting region/zone/woreda/kebele/houseNo above is enough — no need to
|
||||||
// compose it here. companyPhone is derived below (identity → eTrade →
|
// compose it here. companyPhone is derived below (identity → eTrade →
|
||||||
@@ -292,6 +318,7 @@ export default function CompanyProfileForm({
|
|||||||
setValue(
|
setValue(
|
||||||
"etradePhone",
|
"etradePhone",
|
||||||
data.managerPhone || data.regularPhone || data.mobilePhone,
|
data.managerPhone || data.regularPhone || data.mobilePhone,
|
||||||
|
dirty,
|
||||||
);
|
);
|
||||||
|
|
||||||
setEtradeOwner({
|
setEtradeOwner({
|
||||||
@@ -321,28 +348,37 @@ export default function CompanyProfileForm({
|
|||||||
setEtradeOwner(null);
|
setEtradeOwner(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
// companyEmail/companyPhone are no longer typed — the Fayda-verified owner
|
// companyEmail/companyPhone are derived, not typed — the Fayda-verified owner
|
||||||
// is the highest-trust source (that's the whole point of verifying), eTrade's
|
// is the highest-trust source (that's the whole point of verifying), eTrade's
|
||||||
// registered number and the account email/phone are the fallbacks used
|
// registered number and the account email/phone are the fallbacks used
|
||||||
// before verification happens.
|
// before verification happens.
|
||||||
|
//
|
||||||
|
// `firstValid*`, not `??`: these sources are optional AND unreliable. Fayda's
|
||||||
|
// email/phone claims can come back empty, and eTrade's registered phone is
|
||||||
|
// free text that arrives as things like "09 " (→ "+2519"). `??` stops
|
||||||
|
// at the first non-null, so a junk value became a field with no input and a
|
||||||
|
// 400 from the API on a value the customer never typed. Skip anything that
|
||||||
|
// isn't usable and fall through.
|
||||||
|
//
|
||||||
|
// When every source really is unusable the fields become editable below
|
||||||
|
// rather than blocking — the API requires a company email and phone at
|
||||||
|
// submit (REQUIRED_COMPANY_INFO), so leaving no way to supply them is a dead
|
||||||
|
// end.
|
||||||
|
const derivedEmail = firstValidEmail(identity?.owner.email, user.email);
|
||||||
|
const derivedPhone = firstValidPhone(
|
||||||
|
identity?.owner.phone,
|
||||||
|
etradeOwner?.phone,
|
||||||
|
user.phoneNumber,
|
||||||
|
);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setValue("companyEmail", identity?.owner.email ?? user.email ?? "", {
|
if (derivedEmail) setValue("companyEmail", derivedEmail);
|
||||||
shouldValidate: true,
|
|
||||||
});
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [identity?.owner.email, user.email, rehydrate]);
|
}, [derivedEmail, rehydrate]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setValue(
|
if (derivedPhone) setValue("companyPhone", derivedPhone);
|
||||||
"companyPhone",
|
|
||||||
identity?.owner.phone ??
|
|
||||||
etradeOwner?.phone ??
|
|
||||||
toEthiopianE164(user.phoneNumber) ??
|
|
||||||
"",
|
|
||||||
{ shouldValidate: true },
|
|
||||||
);
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [identity?.owner.phone, etradeOwner?.phone, user.phoneNumber, rehydrate]);
|
}, [derivedPhone, rehydrate]);
|
||||||
|
|
||||||
// "Same as …" links. A checked card prefills the target step's fields from the
|
// "Same as …" links. A checked card prefills the target step's fields from the
|
||||||
// source step and disables them (kept mirrored while linked); unchecking clears
|
// source step and disables them (kept mirrored while linked); unchecking clears
|
||||||
@@ -352,6 +388,16 @@ export default function CompanyProfileForm({
|
|||||||
const [gmSameAsOwner, setGmSameAsOwner] = useState(
|
const [gmSameAsOwner, setGmSameAsOwner] = useState(
|
||||||
identity?.gmSameAsOwner ?? false,
|
identity?.gmSameAsOwner ?? false,
|
||||||
);
|
);
|
||||||
|
// `identity` is undefined on the first render (the requirements query is still
|
||||||
|
// in flight), so the initial state above freezes at `false` — adopt the
|
||||||
|
// server's declaration the moment it lands, or a resumed draft shows an
|
||||||
|
// unticked box over a GM that is linked server-side.
|
||||||
|
const identityLoaded = useRef(false);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!identity || identityLoaded.current) return;
|
||||||
|
identityLoaded.current = true;
|
||||||
|
setGmSameAsOwner(identity.gmSameAsOwner);
|
||||||
|
}, [identity]);
|
||||||
const [contactSameAsGm, setContactSameAsGm] = useState(false);
|
const [contactSameAsGm, setContactSameAsGm] = useState(false);
|
||||||
|
|
||||||
// General Manager source. The company step's email/phone are seeded from
|
// General Manager source. The company step's email/phone are seeded from
|
||||||
@@ -365,16 +411,26 @@ export default function CompanyProfileForm({
|
|||||||
// A Fayda-verified owner outranks eTrade's registered owner — it's the
|
// A Fayda-verified owner outranks eTrade's registered owner — it's the
|
||||||
// higher-trust source, and the whole point of proving identity is to stop
|
// higher-trust source, and the whole point of proving identity is to stop
|
||||||
// trusting typed/looked-up data for this.
|
// trusting typed/looked-up data for this.
|
||||||
const gmSourceName =
|
const gmSourceName = firstPresent(
|
||||||
identity?.owner.name ?? etradeOwner?.name ?? user.name?.en ?? "";
|
identity?.owner.name,
|
||||||
const gmSourceEmail =
|
etradeOwner?.name,
|
||||||
identity?.owner.email ?? (companyEmail || user.email || "");
|
user.name?.en,
|
||||||
const gmSourcePhone =
|
);
|
||||||
identity?.owner.phone ??
|
|
||||||
companyPhone ??
|
const gmSourceEmail = firstValidEmail(
|
||||||
etradeOwner?.phone ??
|
identity?.owner.email,
|
||||||
toEthiopianE164(user.phoneNumber) ??
|
companyEmail,
|
||||||
"";
|
user.email,
|
||||||
|
);
|
||||||
|
// Same reason as `derivedPhone`: this value is written into
|
||||||
|
// `generalManagerPhone`, which the API validates with `@IsValidPhone()`, so an
|
||||||
|
// unusable eTrade number here 400s the personnel step instead.
|
||||||
|
const gmSourcePhone = firstValidPhone(
|
||||||
|
identity?.owner.phone,
|
||||||
|
companyPhone,
|
||||||
|
etradeOwner?.phone,
|
||||||
|
user.phoneNumber,
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!gmSameAsOwner) return;
|
if (!gmSameAsOwner) return;
|
||||||
@@ -458,10 +514,24 @@ export default function CompanyProfileForm({
|
|||||||
const gmEstablished =
|
const gmEstablished =
|
||||||
gmVerified || (identity ? !identity.faydaRequired && gmTyped : gmTyped);
|
gmVerified || (identity ? !identity.faydaRequired && gmTyped : gmTyped);
|
||||||
|
|
||||||
/** Same rule for the representative: verified, or typed where Fayda is optional. */
|
/**
|
||||||
|
* Same rule for the representative: verified, or entered where Fayda is
|
||||||
|
* optional.
|
||||||
|
*
|
||||||
|
* Matches the API's own rule (`REQUIRED_POA_FIELDS`): a typed representative
|
||||||
|
* counts once they have a name, an email and a phone. The step now renders
|
||||||
|
* inputs for all three, so this is something the customer can actually
|
||||||
|
* satisfy — previously it gated on `poaName`, for which no input existed
|
||||||
|
* anywhere, leaving a foreign freight forwarder permanently stuck.
|
||||||
|
*/
|
||||||
|
const poaTyped = Boolean(
|
||||||
|
watch("poaName")?.trim() &&
|
||||||
|
watch("poaEmail")?.trim() &&
|
||||||
|
watch("poaPhone")?.trim(),
|
||||||
|
);
|
||||||
const poaEstablished =
|
const poaEstablished =
|
||||||
(identity?.poa.verified ?? false) ||
|
(identity?.poa.verified ?? false) ||
|
||||||
(identity ? !identity.faydaRequired && Boolean(watch("poaName")?.trim()) : false);
|
(identity ? !identity.faydaRequired && poaTyped : false);
|
||||||
|
|
||||||
// While linked, mirror the source values into the (disabled) target fields so
|
// While linked, mirror the source values into the (disabled) target fields so
|
||||||
// the copy stays current even if the user goes back and edits the source.
|
// the copy stays current even if the user goes back and edits the source.
|
||||||
@@ -616,8 +686,12 @@ export default function CompanyProfileForm({
|
|||||||
// Until then the upload is hidden: there is no representative for the paper
|
// Until then the upload is hidden: there is no representative for the paper
|
||||||
// to authorise, and a freight forwarder is held on the verification gate
|
// to authorise, and a freight forwarder is held on the verification gate
|
||||||
// below rather than on a file field it cannot yet fill.
|
// below rather than on a file field it cannot yet fill.
|
||||||
const poaProvided = identity?.poa.verified ?? false;
|
const poaProvided = (identity?.poa.verified ?? false) || poaTyped;
|
||||||
const delegationRequired = poaProvided;
|
// A freight forwarder owes the paper whether or not its representative could
|
||||||
|
// 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
|
||||||
|
// then failed them on submit for a file they were never shown.
|
||||||
|
const delegationRequired = poaProvided || requirePoa;
|
||||||
const delegationPresent =
|
const delegationPresent =
|
||||||
(uploadedDocumentKeys ?? []).includes(POA_DELEGATION_FILE_KEY) ||
|
(uploadedDocumentKeys ?? []).includes(POA_DELEGATION_FILE_KEY) ||
|
||||||
(() => {
|
(() => {
|
||||||
@@ -625,15 +699,63 @@ export default function CompanyProfileForm({
|
|||||||
return Array.isArray(v) ? v.length > 0 : v != null;
|
return Array.isArray(v) ? v.length > 0 : v != null;
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collect the messages for a set of fields into one sentence.
|
||||||
|
*
|
||||||
|
* A failed `trigger()` used to return silently, so Continue simply did
|
||||||
|
* nothing — and every field whose input is conditionally rendered (or derived
|
||||||
|
* and never rendered at all) turned into an invisible dead end. Naming the
|
||||||
|
* failures is the whole point: the ones worth reporting are exactly the ones
|
||||||
|
* with no error text on screen to read.
|
||||||
|
*/
|
||||||
|
const describeErrors = (fields: (keyof FormData)[]): string => {
|
||||||
|
// Re-parse rather than read `errors`: that's the render-time snapshot, and
|
||||||
|
// this runs immediately after an `await trigger()` that has not re-rendered
|
||||||
|
// yet, so the closure would still be holding the previous attempt's state.
|
||||||
|
const parsed = buildOnboardingSchema(
|
||||||
|
identity?.passportRequired === true,
|
||||||
|
).safeParse(getValues());
|
||||||
|
const wanted = new Set<string>(fields as string[]);
|
||||||
|
const messages = parsed.success
|
||||||
|
? []
|
||||||
|
: parsed.error.issues
|
||||||
|
.filter((i) => wanted.has(String(i.path[0])))
|
||||||
|
.map((i) => i.message);
|
||||||
|
return messages.length > 0
|
||||||
|
? `Please fix: ${[...new Set(messages)].join(", ")}.`
|
||||||
|
: "Some details on this step are incomplete. Please review the fields above.";
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The fields this step actually validates. `stepFields` covers what the step
|
||||||
|
* always renders; the company step additionally exposes company email/phone
|
||||||
|
* as inputs when nothing could be derived for them, and a field is validated
|
||||||
|
* exactly when the customer can see and fix it.
|
||||||
|
*/
|
||||||
|
const fieldsForStep = (s: CompanyStep): (keyof FormData)[] => {
|
||||||
|
if (s !== "company" || !identity) return stepFields[s];
|
||||||
|
return [
|
||||||
|
...stepFields.company,
|
||||||
|
...(derivedEmail ? [] : (["companyEmail"] as const)),
|
||||||
|
...(derivedPhone ? [] : (["companyPhone"] as const)),
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
/** Validate + persist the current step, returning whether we may advance. */
|
/** Validate + persist the current step, returning whether we may advance. */
|
||||||
const saveCurrentStep = async (): Promise<boolean> => {
|
const saveCurrentStep = async (): Promise<boolean> => {
|
||||||
setSaveError(null);
|
setSaveError(null);
|
||||||
const isValid = await trigger(stepFields[step]);
|
const fields = fieldsForStep(step);
|
||||||
if (!isValid) return false;
|
const isValid = await trigger(fields);
|
||||||
|
if (!isValid) {
|
||||||
|
setSaveError(describeErrors(fields));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
if (!onSaveStep) return true;
|
if (!onSaveStep) return true;
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
const res = await onSaveStep(stepPayload(step, watch()));
|
const res = await onSaveStep(
|
||||||
|
stepPayload(step, getValues(), dirtyFields),
|
||||||
|
);
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
setSaveError(res.error);
|
setSaveError(res.error);
|
||||||
return false;
|
return false;
|
||||||
@@ -676,7 +798,14 @@ export default function CompanyProfileForm({
|
|||||||
}
|
}
|
||||||
|
|
||||||
setSaveError(null);
|
setSaveError(null);
|
||||||
handleSubmit((data) => onSubmit(buildPayload(data, user)))();
|
// Deliberately NOT `handleSubmit`: that re-validated all 34 schema fields
|
||||||
|
// — including every field belonging to a step that isn't on screen — and
|
||||||
|
// on failure did nothing at all, no alert and no navigation, which is the
|
||||||
|
// "Submit for review" button that appears dead. Each step has already
|
||||||
|
// validated and saved its own fields, and the API's `markOnboardingComplete`
|
||||||
|
// is the authority on what is still outstanding; its message reaches the
|
||||||
|
// customer through `submitError`.
|
||||||
|
onSubmit(buildPayload(getValues(), user));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// The TIN must resolve to a real eTrade record before anything else on
|
// The TIN must resolve to a real eTrade record before anything else on
|
||||||
@@ -728,15 +857,40 @@ export default function CompanyProfileForm({
|
|||||||
setDocumentFieldErrors({
|
setDocumentFieldErrors({
|
||||||
[POA_DELEGATION_FILE_KEY]: "DARS delegation paper is required",
|
[POA_DELEGATION_FILE_KEY]: "DARS delegation paper is required",
|
||||||
});
|
});
|
||||||
|
// Validate the text fields too, so every problem shows at once.
|
||||||
|
const fieldsOk = await trigger(stepFields.poa);
|
||||||
setSaveError(
|
setSaveError(
|
||||||
requirePoa
|
[
|
||||||
? "Freight forwarders must provide Power of Attorney details and the DARS delegation paper."
|
requirePoa
|
||||||
: "Upload the DARS delegation paper for the Power of Attorney you entered, or clear the PoA details to skip.",
|
? "Freight forwarders must provide Power of Attorney details and the DARS delegation paper."
|
||||||
|
: "Upload the DARS delegation paper for the Power of Attorney you entered, or clear the PoA details to skip.",
|
||||||
|
fieldsOk ? null : describeErrors(stepFields.poa),
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" "),
|
||||||
);
|
);
|
||||||
// Fall through to validate the text fields too, so every problem shows at once.
|
|
||||||
await trigger(stepFields.poa);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// The API will not accept the PoA's details until the paper evidencing the
|
||||||
|
// delegation is actually on file, so the selection made on this step has to
|
||||||
|
// be uploaded before the save — not held back until the documents step,
|
||||||
|
// which is unreachable while this save keeps failing.
|
||||||
|
if (step === "poa" && delegationRequired && onUploadDocuments) {
|
||||||
|
const pending = documentFiles[POA_DELEGATION_FILE_KEY];
|
||||||
|
const hasPending = Array.isArray(pending) ? pending.length > 0 : pending != null;
|
||||||
|
if (hasPending) {
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const res = await onUploadDocuments();
|
||||||
|
if (!res.ok) {
|
||||||
|
setSaveError(res.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
// Field steps validate + save before advancing.
|
// Field steps validate + save before advancing.
|
||||||
const ok = await saveCurrentStep();
|
const ok = await saveCurrentStep();
|
||||||
if (!ok) return;
|
if (!ok) return;
|
||||||
@@ -812,6 +966,42 @@ export default function CompanyProfileForm({
|
|||||||
{...register("ownerPassportNumber")}
|
{...register("ownerPassportNumber")}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{/* Normally derived from the verified owner (falling back
|
||||||
|
to eTrade and the account), and shown read-only. Fayda's
|
||||||
|
email and phone claims are optional though, so when
|
||||||
|
every source comes up empty these become typeable —
|
||||||
|
the API requires both at submit, and having no input
|
||||||
|
for them is otherwise an unrecoverable dead end. */}
|
||||||
|
<SimpleGrid cols={2} spacing="md">
|
||||||
|
{derivedEmail ? (
|
||||||
|
<ReadOnlyField
|
||||||
|
label="Company email"
|
||||||
|
value={derivedEmail}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<TextInput
|
||||||
|
label="Company Email"
|
||||||
|
type="email"
|
||||||
|
description="We couldn't find one on your verified identity or account — please enter it."
|
||||||
|
placeholder="company@example.com"
|
||||||
|
error={errors.companyEmail?.message}
|
||||||
|
{...register("companyEmail")}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{derivedPhone ? (
|
||||||
|
<ReadOnlyField
|
||||||
|
label="Company phone"
|
||||||
|
value={derivedPhone}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<ControlledPhoneField
|
||||||
|
control={control}
|
||||||
|
name="companyPhone"
|
||||||
|
label="Company Phone"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</SimpleGrid>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</StepSection>
|
</StepSection>
|
||||||
@@ -835,6 +1025,7 @@ export default function CompanyProfileForm({
|
|||||||
onDataLoaded={handleETradeDataLoaded}
|
onDataLoaded={handleETradeDataLoaded}
|
||||||
onStatusChange={setTinStatus}
|
onStatusChange={setTinStatus}
|
||||||
onReset={handleETradeReset}
|
onReset={handleETradeReset}
|
||||||
|
alreadyVerified={hasRegistrationDetails}
|
||||||
/>
|
/>
|
||||||
{tinVerified && (
|
{tinVerified && (
|
||||||
<ETradeCompanyCard
|
<ETradeCompanyCard
|
||||||
@@ -924,7 +1115,10 @@ export default function CompanyProfileForm({
|
|||||||
<Text fw={600} size="sm" c="edr-text">
|
<Text fw={600} size="sm" c="edr-text">
|
||||||
Contact Person
|
Contact Person
|
||||||
</Text>
|
</Text>
|
||||||
{watch("generalManagerName") && (
|
{/* `gmName`, not the raw form field: a Fayda-verified GM never
|
||||||
|
fills `generalManagerName`, so gating on it hid this card from
|
||||||
|
every Ethiopian company — the majority case. */}
|
||||||
|
{gmName && (
|
||||||
<LinkCheckboxCard
|
<LinkCheckboxCard
|
||||||
checked={contactSameAsGm}
|
checked={contactSameAsGm}
|
||||||
onToggle={toggleContactSameAsGm}
|
onToggle={toggleContactSameAsGm}
|
||||||
@@ -983,21 +1177,51 @@ export default function CompanyProfileForm({
|
|||||||
required={requirePoa}
|
required={requirePoa}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{/* The address comes from the Fayda claim along with the name,
|
{/* A verified representative's details come from the Fayda claim
|
||||||
so it is shown on the panel rather than typed. Only a company
|
and are shown on the panel above. Where Fayda cannot be
|
||||||
whose representative may hold no Fayda ID still types it. */}
|
required — a foreign company whose representative may hold no
|
||||||
|
Fayda ID — they are typed here instead. They have to be: the
|
||||||
|
API refuses to save a freight forwarder's PoA without a name,
|
||||||
|
email and phone (`REQUIRED_POA_FIELDS`), and before this the
|
||||||
|
step rendered no input for any of them, so the customer was
|
||||||
|
told to "add the poa name, poa email, poa phone" with nowhere
|
||||||
|
to add them. */}
|
||||||
{!identity?.poa.verified && !identity?.faydaRequired && (
|
{!identity?.poa.verified && !identity?.faydaRequired && (
|
||||||
<TextInput
|
<>
|
||||||
label="PoA Location"
|
<TextInput
|
||||||
placeholder="City, Country"
|
label="Representative's Name"
|
||||||
error={errors.poaLocation?.message}
|
placeholder="Abebe Bikila"
|
||||||
{...register("poaLocation")}
|
error={errors.poaName?.message}
|
||||||
/>
|
{...register("poaName")}
|
||||||
|
/>
|
||||||
|
<SimpleGrid cols={2} spacing="md">
|
||||||
|
<TextInput
|
||||||
|
label="Representative's Email"
|
||||||
|
type="email"
|
||||||
|
placeholder="poa@company.com"
|
||||||
|
error={errors.poaEmail?.message}
|
||||||
|
{...register("poaEmail")}
|
||||||
|
/>
|
||||||
|
<ControlledPhoneField
|
||||||
|
control={control}
|
||||||
|
name="poaPhone"
|
||||||
|
label="Representative's Phone"
|
||||||
|
/>
|
||||||
|
</SimpleGrid>
|
||||||
|
<TextInput
|
||||||
|
label="PoA Location"
|
||||||
|
placeholder="City, Country"
|
||||||
|
error={errors.poaLocation?.message}
|
||||||
|
{...register("poaLocation")}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* The paper authorises the representative the verification
|
{/* The paper authorises the representative, so it shows once one
|
||||||
named, so it only has meaning once one exists. */}
|
exists — or straight away for a freight forwarder, who owes it
|
||||||
{poaProvided && poaDocumentSetting && (
|
either way and must not be failed on submit for a file the
|
||||||
|
step never offered. */}
|
||||||
|
{delegationRequired && poaDocumentSetting && (
|
||||||
<>
|
<>
|
||||||
<Divider my="sm" />
|
<Divider my="sm" />
|
||||||
<SmartFileInput
|
<SmartFileInput
|
||||||
|
|||||||
@@ -17,6 +17,11 @@ import { ReadOnlyField } from "./ReadOnlyField";
|
|||||||
* supplied a value, but falls back to an editable input when eTrade left it
|
* supplied a value, but falls back to an editable input when eTrade left it
|
||||||
* blank — otherwise a gap in eTrade's own data would leave the field
|
* blank — otherwise a gap in eTrade's own data would leave the field
|
||||||
* permanently empty and the user stuck (zod requires all of these).
|
* permanently empty and the user stuck (zod requires all of these).
|
||||||
|
*
|
||||||
|
* A value that fails validation unlocks the same way. eTrade (or a row saved
|
||||||
|
* before the current rules) can supply something the schema rejects, and a
|
||||||
|
* rejected value rendered read-only is a step that can never be completed and
|
||||||
|
* never says why.
|
||||||
*/
|
*/
|
||||||
function LockedField({
|
function LockedField({
|
||||||
label,
|
label,
|
||||||
@@ -32,7 +37,7 @@ function LockedField({
|
|||||||
errors: FieldErrors<FormData>;
|
errors: FieldErrors<FormData>;
|
||||||
}) {
|
}) {
|
||||||
const value = watch(name) as string | undefined;
|
const value = watch(name) as string | undefined;
|
||||||
if (value && value.trim()) {
|
if (value && value.trim() && !errors[name]) {
|
||||||
return <ReadOnlyField label={label} value={value} />;
|
return <ReadOnlyField label={label} value={value} />;
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
@@ -96,7 +101,12 @@ export default function ETradeCompanyCard({
|
|||||||
<ReadOnlyField label="Renewal Date" value={watch("renewalDate")} />
|
<ReadOnlyField label="Renewal Date" value={watch("renewalDate")} />
|
||||||
<ReadOnlyField label="Renewed From" value={watch("renewedFrom")} />
|
<ReadOnlyField label="Renewed From" value={watch("renewedFrom")} />
|
||||||
<ReadOnlyField label="Renewed To" value={watch("renewedTo")} />
|
<ReadOnlyField label="Renewed To" value={watch("renewedTo")} />
|
||||||
{region && region.trim() ? (
|
{/* Membership of the catalog, not mere presence: eTrade's normalizer
|
||||||
|
returns null for a region it doesn't recognise, and older rows can
|
||||||
|
hold a spelling that isn't in the list. Showing such a value
|
||||||
|
read-only left the customer with a required field they could not
|
||||||
|
correct. */}
|
||||||
|
{(ETHIOPIAN_REGIONS as readonly string[]).includes(region ?? "") ? (
|
||||||
<ReadOnlyField label="Region" value={region} />
|
<ReadOnlyField label="Region" value={region} />
|
||||||
) : (
|
) : (
|
||||||
<Controller
|
<Controller
|
||||||
|
|||||||
@@ -1,8 +1,72 @@
|
|||||||
import type { AuthUser } from "@/types/auth";
|
import type { AuthUser } from "@/types/auth";
|
||||||
import type { CreateCompanyPayload } from "@/services/companies.service";
|
import type { CreateCompanyPayload } from "@/services/companies.service";
|
||||||
import type { ProfileResponse, UpdateProfilePayload } from "@/types/profile";
|
import type { ProfileResponse, UpdateProfilePayload } from "@/types/profile";
|
||||||
|
import type { CompanyIdentityState } from "@/services/verifayda.service";
|
||||||
|
import { isValidPhone, toEthiopianE164 } from "@/components/PhoneField";
|
||||||
|
|
||||||
import type { CompanyStep, FormData } from "./schema";
|
import { ETRADE_BUNDLE_FIELDS, type CompanyStep, type FormData } from "./schema";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* First value that is actually present.
|
||||||
|
*
|
||||||
|
* `??` is wrong for these: an identity claim Fayda returned as an empty string
|
||||||
|
* is not a value, but it isn't null either, so `??` would stop there and hand
|
||||||
|
* the form a blank it has no input to fix.
|
||||||
|
*/
|
||||||
|
export const firstPresent = (...values: (string | null | undefined)[]): string =>
|
||||||
|
values.find((v) => v && v.trim())?.trim() ?? "";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* First candidate that is actually a usable phone number, normalized to E.164.
|
||||||
|
*
|
||||||
|
* Presence is not enough here. eTrade's registered phone is free text and comes
|
||||||
|
* back as things like `"09 "`, which normalizes to `+2519` — non-empty,
|
||||||
|
* so a "first present" pick would take it, hand it to a field with no input,
|
||||||
|
* and have the API reject the whole save with
|
||||||
|
* "companyPhone must be a valid international phone number" for something the
|
||||||
|
* customer never typed. Skip a source that cannot produce a valid number and
|
||||||
|
* fall through to the next one.
|
||||||
|
*/
|
||||||
|
export const firstValidPhone = (
|
||||||
|
...values: (string | null | undefined)[]
|
||||||
|
): string => {
|
||||||
|
for (const raw of values) {
|
||||||
|
if (!raw || !raw.trim()) continue;
|
||||||
|
const e164 = toEthiopianE164(raw);
|
||||||
|
if (e164 && isValidPhone(e164)) return e164;
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Same idea for email: a malformed claim must not become an unfixable field. */
|
||||||
|
export const firstValidEmail = (
|
||||||
|
...values: (string | null | undefined)[]
|
||||||
|
): string => {
|
||||||
|
const ok = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
|
return values.find((v) => v && ok.test(v.trim()))?.trim() ?? "";
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fayda reports a person's phone as the national registry holds it, which is
|
||||||
|
* routinely a local number ("0911223344"). Every phone the forms validate and
|
||||||
|
* submit is E.164, so normalize on the way in — the API now stores new
|
||||||
|
* verifications normalized, but rows verified before that still hold raw claims.
|
||||||
|
*/
|
||||||
|
export function normalizeIdentityPhones(
|
||||||
|
identity?: CompanyIdentityState,
|
||||||
|
): CompanyIdentityState | undefined {
|
||||||
|
if (!identity) return identity;
|
||||||
|
const fix = <T extends { phone: string | null }>(person: T): T => ({
|
||||||
|
...person,
|
||||||
|
phone: person.phone ? toEthiopianE164(person.phone) : person.phone,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
...identity,
|
||||||
|
owner: fix(identity.owner),
|
||||||
|
poa: fix(identity.poa),
|
||||||
|
gm: fix(identity.gm),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/** Last 9 digits (Ethiopian national significant number) for tolerant compare. */
|
/** Last 9 digits (Ethiopian national significant number) for tolerant compare. */
|
||||||
export const phoneDigits = (p?: string | null) =>
|
export const phoneDigits = (p?: string | null) =>
|
||||||
@@ -44,34 +108,35 @@ export function buildPayload(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Map one wizard step's form values to the profile-update payload it saves. */
|
/**
|
||||||
|
* Map one wizard step's form values to the profile-update payload it saves.
|
||||||
|
*
|
||||||
|
* `dirty` is react-hook-form's `dirtyFields`. The eTrade-owned keys (and the
|
||||||
|
* TIN) ride along only when the customer actually changed them this session —
|
||||||
|
* see `ETRADE_BUNDLE_FIELDS`. Everything else is unconditional: the API treats
|
||||||
|
* an absent key as "untouched", so omitting a field never clears it.
|
||||||
|
*/
|
||||||
export function stepPayload(
|
export function stepPayload(
|
||||||
step: CompanyStep,
|
step: CompanyStep,
|
||||||
d: FormData,
|
d: FormData,
|
||||||
|
dirty: Partial<Record<keyof FormData, unknown>> = {},
|
||||||
): Partial<UpdateProfilePayload> {
|
): Partial<UpdateProfilePayload> {
|
||||||
switch (step) {
|
switch (step) {
|
||||||
case "company":
|
case "company": {
|
||||||
|
const etrade: Partial<UpdateProfilePayload> = {};
|
||||||
|
for (const key of ETRADE_BUNDLE_FIELDS) {
|
||||||
|
if (dirty[key]) (etrade as Record<string, unknown>)[key] = d[key];
|
||||||
|
}
|
||||||
|
if (dirty.tinNumber) etrade.tin = d.tinNumber;
|
||||||
return {
|
return {
|
||||||
companyName: d.companyName,
|
|
||||||
companyEmail: d.companyEmail,
|
companyEmail: d.companyEmail,
|
||||||
companyPhone: d.companyPhone,
|
companyPhone: d.companyPhone,
|
||||||
companyAddress: d.companyAddress,
|
companyAddress: d.companyAddress,
|
||||||
tin: d.tinNumber,
|
|
||||||
vatNumber: d.vatNumber,
|
vatNumber: d.vatNumber,
|
||||||
ownerPassportNumber: d.ownerPassportNumber || undefined,
|
ownerPassportNumber: d.ownerPassportNumber || undefined,
|
||||||
licenceNumber: d.licenceNumber,
|
...etrade,
|
||||||
statusDescription: d.statusDescription,
|
|
||||||
dateRegistered: d.dateRegistered,
|
|
||||||
renewedFrom: d.renewedFrom,
|
|
||||||
renewalDate: d.renewalDate,
|
|
||||||
renewedTo: d.renewedTo,
|
|
||||||
region: d.region,
|
|
||||||
zone: d.zone,
|
|
||||||
woreda: d.woreda,
|
|
||||||
kebele: d.kebele,
|
|
||||||
houseNo: d.houseNo,
|
|
||||||
etradePhone: d.etradePhone,
|
|
||||||
};
|
};
|
||||||
|
}
|
||||||
case "personnel":
|
case "personnel":
|
||||||
return {
|
return {
|
||||||
generalManagerName: d.generalManagerName,
|
generalManagerName: d.generalManagerName,
|
||||||
@@ -86,7 +151,12 @@ export function stepPayload(
|
|||||||
contactPersonPhone: d.contactPersonPhone,
|
contactPersonPhone: d.contactPersonPhone,
|
||||||
};
|
};
|
||||||
case "poa":
|
case "poa":
|
||||||
return { poaLocation: d.poaLocation || undefined };
|
return {
|
||||||
|
poaName: d.poaName || undefined,
|
||||||
|
poaEmail: d.poaEmail || undefined,
|
||||||
|
poaPhone: d.poaPhone || undefined,
|
||||||
|
poaLocation: d.poaLocation || undefined,
|
||||||
|
};
|
||||||
default:
|
default:
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,180 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { onboardingSchema, stepFields } from "./schema";
|
||||||
|
import {
|
||||||
|
firstPresent,
|
||||||
|
firstValidEmail,
|
||||||
|
firstValidPhone,
|
||||||
|
normalizeIdentityPhones,
|
||||||
|
stepPayload,
|
||||||
|
} from "./helpers";
|
||||||
|
import type { FormData } from "./schema";
|
||||||
|
import type { CompanyIdentityState } from "@/services/verifayda.service";
|
||||||
|
|
||||||
|
/** A minimally-valid form, so each case can vary one field at a time. */
|
||||||
|
const values = (over: Partial<FormData> = {}): FormData =>
|
||||||
|
({
|
||||||
|
companyName: "Acme PLC",
|
||||||
|
companyEmail: "acme@example.com",
|
||||||
|
companyPhone: "+251911223344",
|
||||||
|
companyAddress: "1, Bole, Bole, Addis Ababa",
|
||||||
|
etradePhone: "+251911223344",
|
||||||
|
tinNumber: "0012345678",
|
||||||
|
vatNumber: "0012345678",
|
||||||
|
ownerPassportNumber: "",
|
||||||
|
licenceNumber: "LIC-1",
|
||||||
|
statusDescription: "Active",
|
||||||
|
dateRegistered: "2020-01-01",
|
||||||
|
renewedFrom: "",
|
||||||
|
renewalDate: "",
|
||||||
|
renewedTo: "",
|
||||||
|
region: "Addis Ababa",
|
||||||
|
zone: "Bole",
|
||||||
|
woreda: "03",
|
||||||
|
kebele: "07",
|
||||||
|
houseNo: "1",
|
||||||
|
contactPersonName: "Jane Smith",
|
||||||
|
contactPersonPosition: "",
|
||||||
|
contactPersonEmail: "",
|
||||||
|
contactPersonPhone: "+251911223344",
|
||||||
|
generalManagerName: "",
|
||||||
|
generalManagerEmail: "",
|
||||||
|
generalManagerPhone: "",
|
||||||
|
poaName: "",
|
||||||
|
poaPhone: "",
|
||||||
|
poaAddress: "",
|
||||||
|
poaEmail: "",
|
||||||
|
poaLocation: "",
|
||||||
|
...over,
|
||||||
|
}) as FormData;
|
||||||
|
|
||||||
|
const errorFor = (data: FormData, field: keyof FormData) => {
|
||||||
|
const parsed = onboardingSchema.safeParse(data);
|
||||||
|
if (parsed.success) return undefined;
|
||||||
|
return parsed.error.issues.find((i) => i.path[0] === field)?.message;
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("VAT number", () => {
|
||||||
|
it("accepts exactly ten digits", () => {
|
||||||
|
expect(errorFor(values({ vatNumber: "0012345678" }), "vatNumber")).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
// `.length(10)` used to pass this, so a ten-letter string reached the API.
|
||||||
|
it("rejects ten non-digits", () => {
|
||||||
|
expect(errorFor(values({ vatNumber: "ABCDEFGHIJ" }), "vatNumber")).toBe(
|
||||||
|
"VAT number must be exactly 10 digits",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects blank", () => {
|
||||||
|
expect(errorFor(values({ vatNumber: "" }), "vatNumber")).toBe(
|
||||||
|
"VAT number is required",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("region", () => {
|
||||||
|
it("rejects a spelling outside the catalog", () => {
|
||||||
|
expect(errorFor(values({ region: "Addis Abeba City" }), "region")).toBe(
|
||||||
|
"Region is required",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("stepFields", () => {
|
||||||
|
// The regression this whole change exists to prevent: a step must not gate on
|
||||||
|
// a field it renders no input for, or Continue fails with the error attached
|
||||||
|
// to nothing on screen.
|
||||||
|
it("never gates the company step on a derived or read-only field", () => {
|
||||||
|
const unreachable = [
|
||||||
|
"companyEmail",
|
||||||
|
"companyPhone",
|
||||||
|
"companyAddress",
|
||||||
|
"etradePhone",
|
||||||
|
"licenceNumber",
|
||||||
|
"statusDescription",
|
||||||
|
"dateRegistered",
|
||||||
|
"renewedFrom",
|
||||||
|
"renewalDate",
|
||||||
|
"renewedTo",
|
||||||
|
];
|
||||||
|
expect(
|
||||||
|
stepFields.company.filter((f) => unreachable.includes(f)),
|
||||||
|
).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("stepPayload (company)", () => {
|
||||||
|
it("omits the eTrade bundle when nothing was re-verified", () => {
|
||||||
|
const payload = stepPayload("company", values(), {});
|
||||||
|
expect(payload.tin).toBeUndefined();
|
||||||
|
expect(payload.region).toBeUndefined();
|
||||||
|
expect(payload.licenceNumber).toBeUndefined();
|
||||||
|
// The customer's own fields still save.
|
||||||
|
expect(payload.vatNumber).toBe("0012345678");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes the bundle and the TIN once they are dirty", () => {
|
||||||
|
const payload = stepPayload("company", values(), {
|
||||||
|
tinNumber: true,
|
||||||
|
region: true,
|
||||||
|
});
|
||||||
|
expect(payload.tin).toBe("0012345678");
|
||||||
|
expect(payload.region).toBe("Addis Ababa");
|
||||||
|
// Still only the dirty ones.
|
||||||
|
expect(payload.licenceNumber).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("firstPresent", () => {
|
||||||
|
it("skips empty strings rather than stopping at them", () => {
|
||||||
|
expect(firstPresent("", " ", "second@example.com")).toBe(
|
||||||
|
"second@example.com",
|
||||||
|
);
|
||||||
|
expect(firstPresent(null, undefined, "")).toBe("");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("firstValidPhone", () => {
|
||||||
|
// Observed live: eTrade returned "09 " for a real TIN. It normalizes to
|
||||||
|
// "+2519", which is non-empty — so a presence check took it, put it in a field
|
||||||
|
// with no input, and the API rejected the whole step.
|
||||||
|
it("skips an eTrade number that cannot make a valid E.164", () => {
|
||||||
|
expect(firstValidPhone("09 ", "+251911223344")).toBe("+251911223344");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("normalizes a local number it can use", () => {
|
||||||
|
expect(firstValidPhone("0911223344")).toBe("+251911223344");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns empty when no source is usable, so the field falls back to an input", () => {
|
||||||
|
expect(firstValidPhone("09 ", "", null)).toBe("");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("firstValidEmail", () => {
|
||||||
|
it("skips a malformed claim", () => {
|
||||||
|
expect(firstValidEmail("not-an-email", "real@example.com")).toBe(
|
||||||
|
"real@example.com",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("normalizeIdentityPhones", () => {
|
||||||
|
it("converts a local Fayda phone claim to E.164", () => {
|
||||||
|
const identity = {
|
||||||
|
faydaRequired: true,
|
||||||
|
passportRequired: false,
|
||||||
|
owner: { verified: true, name: "A", phone: "0911223344", email: null, address: null, verifiedAt: null, passportNumber: null },
|
||||||
|
poa: { verified: false, name: null, phone: null, email: null, address: null, verifiedAt: null },
|
||||||
|
gm: { verified: false, name: null, phone: "251911223344", email: null, address: null, verifiedAt: null },
|
||||||
|
gmSameAsOwner: false,
|
||||||
|
complete: false,
|
||||||
|
} as CompanyIdentityState;
|
||||||
|
|
||||||
|
const fixed = normalizeIdentityPhones(identity)!;
|
||||||
|
expect(fixed.owner.phone).toBe("+251911223344");
|
||||||
|
expect(fixed.gm.phone).toBe("+251911223344");
|
||||||
|
expect(fixed.poa.phone).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -26,10 +26,11 @@ export const onboardingSchema = z.object({
|
|||||||
// can diverge without the backend's eTrade-authenticity check misfiring.
|
// can diverge without the backend's eTrade-authenticity check misfiring.
|
||||||
etradePhone: z.string().optional(),
|
etradePhone: z.string().optional(),
|
||||||
tinNumber: z.string().regex(/^\d{10}$/, "TIN must be exactly 10 digits"),
|
tinNumber: z.string().regex(/^\d{10}$/, "TIN must be exactly 10 digits"),
|
||||||
|
// `.length(10)` alone accepted "ABCDEFGHIJ" — the check has to be on digits.
|
||||||
vatNumber: z
|
vatNumber: z
|
||||||
.string()
|
.string()
|
||||||
.min(1, "VAT number is required")
|
.min(1, "VAT number is required")
|
||||||
.length(10, "VAT number must be exactly 10 digits"),
|
.regex(/^\d{10}$/, "VAT number must be exactly 10 digits"),
|
||||||
// The owner's passport number — the foreign-company identity credential
|
// The owner's passport number — the foreign-company identity credential
|
||||||
// (Fayda is an Ethiopian national ID). Required only for a foreign company;
|
// (Fayda is an Ethiopian national ID). Required only for a foreign company;
|
||||||
// enforced in buildOnboardingSchema since that depends on `nationality`.
|
// enforced in buildOnboardingSchema since that depends on `nationality`.
|
||||||
@@ -134,22 +135,47 @@ export function buildOnboardingSchema(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The keys eTrade owns. They are only resent when the customer actually
|
||||||
|
* re-verified the TIN this session: the API reacts to *any* of them by issuing
|
||||||
|
* a live eTrade lookup (`applyEtradeSourcedFields`) whose transport failures
|
||||||
|
* come back as a 400, so echoing unchanged values back would let an eTrade
|
||||||
|
* outage block a save the customer never made.
|
||||||
|
*/
|
||||||
|
export const ETRADE_BUNDLE_FIELDS = [
|
||||||
|
"companyName",
|
||||||
|
"licenceNumber",
|
||||||
|
"statusDescription",
|
||||||
|
"dateRegistered",
|
||||||
|
"renewedFrom",
|
||||||
|
"renewalDate",
|
||||||
|
"renewedTo",
|
||||||
|
"region",
|
||||||
|
"zone",
|
||||||
|
"woreda",
|
||||||
|
"kebele",
|
||||||
|
"houseNo",
|
||||||
|
"etradePhone",
|
||||||
|
] as const satisfies readonly (keyof FormData)[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What each step validates before it may advance.
|
||||||
|
*
|
||||||
|
* Hard rule: a key belongs here only if that step renders an input the customer
|
||||||
|
* can actually correct it in. `companyEmail`/`companyPhone` are derived from the
|
||||||
|
* Fayda identity / eTrade / the account and have no input of their own, and the
|
||||||
|
* read-only eTrade fields cannot be edited at all — listing them meant a value
|
||||||
|
* the customer never typed could fail zod with its error message attached to
|
||||||
|
* nothing on screen, which reads as a Continue button that silently does
|
||||||
|
* nothing. The server still enforces its own required-field list at submit
|
||||||
|
* (`REQUIRED_COMPANY_INFO`), and reports it with a message.
|
||||||
|
*/
|
||||||
export const stepFields: Record<CompanyStep, (keyof FormData)[]> = {
|
export const stepFields: Record<CompanyStep, (keyof FormData)[]> = {
|
||||||
company: [
|
company: [
|
||||||
"companyName",
|
"companyName",
|
||||||
"companyEmail",
|
|
||||||
"companyPhone",
|
|
||||||
"companyAddress",
|
|
||||||
"etradePhone",
|
|
||||||
"tinNumber",
|
"tinNumber",
|
||||||
"vatNumber",
|
"vatNumber",
|
||||||
"ownerPassportNumber",
|
"ownerPassportNumber",
|
||||||
"licenceNumber",
|
|
||||||
"statusDescription",
|
|
||||||
"dateRegistered",
|
|
||||||
"renewedFrom",
|
|
||||||
"renewalDate",
|
|
||||||
"renewedTo",
|
|
||||||
"region",
|
"region",
|
||||||
"zone",
|
"zone",
|
||||||
"woreda",
|
"woreda",
|
||||||
@@ -167,7 +193,11 @@ export const stepFields: Record<CompanyStep, (keyof FormData)[]> = {
|
|||||||
"contactPersonEmail",
|
"contactPersonEmail",
|
||||||
"contactPersonPhone",
|
"contactPersonPhone",
|
||||||
],
|
],
|
||||||
poa: ["poaLocation"],
|
// The API requires poaName/poaEmail/poaPhone from a freight forwarder
|
||||||
|
// (`REQUIRED_POA_FIELDS`), so the step has to offer them wherever the
|
||||||
|
// representative isn't proven by Fayda — otherwise the save is rejected
|
||||||
|
// naming fields the form never rendered.
|
||||||
|
poa: ["poaName", "poaEmail", "poaPhone", "poaLocation"],
|
||||||
documents: [],
|
documents: [],
|
||||||
additional: [],
|
additional: [],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,194 @@
|
|||||||
|
import { Button, Center, Checkbox, Loader, Stack, Text } from "@mantine/core";
|
||||||
|
import { DatePickerInput } from "@mantine/dates";
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
|
||||||
|
import toast from "react-hot-toast";
|
||||||
|
|
||||||
|
import { bookingsService } from "@/services/bookings.service";
|
||||||
|
import { lastMileRequestsService } from "@/services/last-mile-requests.service";
|
||||||
|
|
||||||
|
import { CardTitle, PageShell, SectionCard } from "../BookingDetailPage/components/layout";
|
||||||
|
|
||||||
|
const errorMessage = (error: unknown, fallback: string) => {
|
||||||
|
const data = (error as { response?: { data?: { message?: string | string[] } } })
|
||||||
|
?.response?.data;
|
||||||
|
if (Array.isArray(data?.message)) return data.message.join(", ");
|
||||||
|
if (data?.message) return data.message;
|
||||||
|
return error instanceof Error ? error.message : fallback;
|
||||||
|
};
|
||||||
|
|
||||||
|
const STATUS_MESSAGE: Record<string, string> = {
|
||||||
|
SUBMITTED: "submitted",
|
||||||
|
APPROVED: "approved",
|
||||||
|
REJECTED: "rejected",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function LastMileConfirmPage() {
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const requestId = searchParams.get("requestId");
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const [selected, setSelected] = useState<string[]>([]);
|
||||||
|
const [deliveryDate, setDeliveryDate] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: request,
|
||||||
|
isLoading: requestLoading,
|
||||||
|
isError: requestError,
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: ["last-mile-request", requestId],
|
||||||
|
queryFn: () => lastMileRequestsService.get(requestId!),
|
||||||
|
enabled: !!requestId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: booking, isLoading: bookingLoading } = useQuery({
|
||||||
|
queryKey: ["booking", id],
|
||||||
|
queryFn: () => bookingsService.get(id!),
|
||||||
|
enabled: !!id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const containerNumbers = booking?.containerNumbers ?? [];
|
||||||
|
|
||||||
|
const submitMutation = useMutation({
|
||||||
|
mutationFn: () => lastMileRequestsService.submit(requestId!, selected, deliveryDate!),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success("Last-mile confirmation submitted");
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["last-mile-request", requestId] });
|
||||||
|
navigate(`/bookings/${id}`);
|
||||||
|
},
|
||||||
|
onError: (e) => toast.error(errorMessage(e, "Could not submit confirmation")),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!requestId) {
|
||||||
|
return (
|
||||||
|
<PageShell>
|
||||||
|
<SectionCard p={22}>
|
||||||
|
<Text c="dimmed">Missing request id.</Text>
|
||||||
|
</SectionCard>
|
||||||
|
</PageShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (requestLoading || bookingLoading) {
|
||||||
|
return (
|
||||||
|
<Center mih={300} p="xl">
|
||||||
|
<Loader color="edr-green" />
|
||||||
|
</Center>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (requestError || !request) {
|
||||||
|
return (
|
||||||
|
<PageShell>
|
||||||
|
<SectionCard p={22}>
|
||||||
|
<Text c="dimmed">Could not load this confirmation request.</Text>
|
||||||
|
</SectionCard>
|
||||||
|
</PageShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.status !== "AWAITING_CONFIRMATION") {
|
||||||
|
return (
|
||||||
|
<PageShell>
|
||||||
|
<SectionCard p={22}>
|
||||||
|
<CardTitle>Last-mile confirmation</CardTitle>
|
||||||
|
<Text mt={12}>
|
||||||
|
This request has already been {STATUS_MESSAGE[request.status]}.
|
||||||
|
</Text>
|
||||||
|
{request.status === "REJECTED" && request.rejectionReason && (
|
||||||
|
<Text mt={8} c="dimmed" fz="sm">
|
||||||
|
Reason: {request.rejectionReason}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{request.status === "APPROVED" && (
|
||||||
|
<>
|
||||||
|
<Text mt={8} c="dimmed" fz="sm">
|
||||||
|
{request.customerSignedAt
|
||||||
|
? "You have signed the last-mile contract."
|
||||||
|
: "Review and sign the last-mile contract to receive your advance invoice."}
|
||||||
|
</Text>
|
||||||
|
<Button
|
||||||
|
mt={16}
|
||||||
|
onClick={() =>
|
||||||
|
navigate(`/bookings/${id}/last-mile-contract?requestId=${requestId}`)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{request.customerSignedAt ? "View LM contract" : "View and Sign LM"}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</SectionCard>
|
||||||
|
</PageShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const allSelected =
|
||||||
|
containerNumbers.length > 0 && selected.length === containerNumbers.length;
|
||||||
|
|
||||||
|
const toggleAll = (checked: boolean) => {
|
||||||
|
setSelected(checked ? [...containerNumbers] : []);
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleOne = (containerNumber: string, checked: boolean) => {
|
||||||
|
setSelected((prev) =>
|
||||||
|
checked ? [...prev, containerNumber] : prev.filter((c) => c !== containerNumber),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageShell>
|
||||||
|
<SectionCard p={22}>
|
||||||
|
<CardTitle>Confirm last-mile containers</CardTitle>
|
||||||
|
<Text mt={8} mb={16} fz="sm" c="dimmed">
|
||||||
|
Select which containers on this booking should be delivered via EDR
|
||||||
|
last-mile.
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Stack gap={8}>
|
||||||
|
<Checkbox
|
||||||
|
label="Select all"
|
||||||
|
checked={allSelected}
|
||||||
|
onChange={(e) => toggleAll(e.currentTarget.checked)}
|
||||||
|
fw={700}
|
||||||
|
/>
|
||||||
|
{containerNumbers.map((c) => (
|
||||||
|
<Checkbox
|
||||||
|
key={c}
|
||||||
|
label={c}
|
||||||
|
checked={selected.includes(c)}
|
||||||
|
onChange={(e) => toggleOne(c, e.currentTarget.checked)}
|
||||||
|
ml={12}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<DatePickerInput
|
||||||
|
mt={16}
|
||||||
|
label="Delivery date"
|
||||||
|
description="When should we deliver? Pick a date after the train departs Djibouti."
|
||||||
|
placeholder="Pick the last-mile delivery date"
|
||||||
|
value={deliveryDate}
|
||||||
|
onChange={(date) =>
|
||||||
|
setDeliveryDate(
|
||||||
|
date ? new Date(date).toISOString().slice(0, 10) : null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
minDate={new Date()}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
mt={20}
|
||||||
|
disabled={selected.length === 0 || !deliveryDate}
|
||||||
|
loading={submitMutation.isPending}
|
||||||
|
onClick={() => submitMutation.mutate()}
|
||||||
|
>
|
||||||
|
Submit confirmation
|
||||||
|
</Button>
|
||||||
|
</SectionCard>
|
||||||
|
</PageShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,240 @@
|
|||||||
|
import { useCallback, useRef, useState } from "react";
|
||||||
|
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { ArrowLeft, Download, FileSignature, Loader2, Printer } from "lucide-react";
|
||||||
|
import toast from "react-hot-toast";
|
||||||
|
|
||||||
|
import { ContractSignaturePad } from "@/components/bookings/ContractSignaturePad";
|
||||||
|
import {
|
||||||
|
lastMileRequestsService,
|
||||||
|
type SignLastMileContractPayload,
|
||||||
|
} from "@/services/last-mile-requests.service";
|
||||||
|
import { Button } from "@edr/ui-common";
|
||||||
|
|
||||||
|
const CONSENT_TEXT = "I agree to the terms of this last-mile delivery contract.";
|
||||||
|
|
||||||
|
export default function LastMileContractPage() {
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const requestId = searchParams.get("requestId");
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||||
|
const [signOpen, setSignOpen] = useState(false);
|
||||||
|
const [agreed, setAgreed] = useState(false);
|
||||||
|
const [signerName, setSignerName] = useState("");
|
||||||
|
const [signatureData, setSignatureData] = useState<string | null>(null);
|
||||||
|
// When a saved signature exists we offer it for approval first; the customer
|
||||||
|
// can switch to drawing a fresh one.
|
||||||
|
const [drawNew, setDrawNew] = useState(false);
|
||||||
|
|
||||||
|
const { data, isLoading, isError, refetch } = useQuery({
|
||||||
|
queryKey: ["last-mile-contract-view", requestId],
|
||||||
|
queryFn: () => lastMileRequestsService.getContractView(requestId!),
|
||||||
|
enabled: Boolean(requestId),
|
||||||
|
});
|
||||||
|
|
||||||
|
const savedSignature = data?.savedSignature ?? null;
|
||||||
|
const savedSignatureImage = savedSignature?.signatureImageUrl ?? null;
|
||||||
|
const usingSaved = Boolean(savedSignatureImage) && !drawNew;
|
||||||
|
|
||||||
|
const openSign = () => {
|
||||||
|
setSignerName(savedSignature?.signerDisplayName ?? "");
|
||||||
|
setSignatureData(null);
|
||||||
|
setDrawNew(false);
|
||||||
|
setAgreed(false);
|
||||||
|
setSignOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const signMutation = useMutation({
|
||||||
|
mutationFn: (payload: SignLastMileContractPayload) =>
|
||||||
|
lastMileRequestsService.signContract(requestId!, payload),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success("Contract signed — your advance invoice is ready");
|
||||||
|
setSignOpen(false);
|
||||||
|
void refetch();
|
||||||
|
qc.invalidateQueries({ queryKey: ["last-mile-request", requestId] });
|
||||||
|
navigate("/billing");
|
||||||
|
},
|
||||||
|
onError: () => toast.error("Failed to sign contract"),
|
||||||
|
});
|
||||||
|
|
||||||
|
const confirmSign = () => {
|
||||||
|
if (!signerName.trim() || !agreed) return;
|
||||||
|
const image = usingSaved ? savedSignatureImage : signatureData;
|
||||||
|
if (!image) return;
|
||||||
|
signMutation.mutate({
|
||||||
|
signatureImageBase64: image,
|
||||||
|
signerDisplayName: signerName.trim(),
|
||||||
|
consentText: CONSENT_TEXT,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const downloadPdf = useCallback(async () => {
|
||||||
|
if (!requestId) return;
|
||||||
|
try {
|
||||||
|
const blob = await lastMileRequestsService.downloadContractDocument(requestId);
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = url;
|
||||||
|
a.download = `LM_${data?.bookingReference ?? requestId}.pdf`;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
} catch {
|
||||||
|
toast.error("PDF not ready yet. Contact EDR if this persists.");
|
||||||
|
}
|
||||||
|
}, [requestId, data?.bookingReference]);
|
||||||
|
|
||||||
|
const handlePrint = useCallback(() => {
|
||||||
|
if (iframeRef.current?.contentWindow) {
|
||||||
|
iframeRef.current.contentWindow.print();
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!requestId) {
|
||||||
|
return (
|
||||||
|
<div className="p-8">
|
||||||
|
<p className="text-muted-foreground">Missing request id.</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-[40vh] items-center justify-center">
|
||||||
|
<Loader2 className="size-8 animate-spin text-primary" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isError || !data) {
|
||||||
|
return (
|
||||||
|
<div className="p-8">
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Could not load the last-mile contract. It becomes available once your
|
||||||
|
request is approved.
|
||||||
|
</p>
|
||||||
|
<Button variant="outline" className="mt-4" onClick={() => navigate(-1)}>
|
||||||
|
Go back
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-muted/30 p-4 md:p-8">
|
||||||
|
<div className="mx-auto max-w-4xl">
|
||||||
|
<div className="mb-4 flex flex-wrap items-center justify-between gap-3 print:hidden">
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => navigate(`/bookings/${id}`)}>
|
||||||
|
<ArrowLeft className="mr-2 size-4" />
|
||||||
|
Back to booking
|
||||||
|
</Button>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button variant="outline" size="sm" onClick={handlePrint}>
|
||||||
|
<Printer className="mr-2 size-4" />
|
||||||
|
Print
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" onClick={downloadPdf}>
|
||||||
|
<Download className="mr-2 size-4" />
|
||||||
|
PDF
|
||||||
|
</Button>
|
||||||
|
{data.canSign && (
|
||||||
|
<Button size="sm" onClick={openSign}>
|
||||||
|
<FileSignature className="mr-2 size-4" />
|
||||||
|
{usingSaved ? "Approve & sign" : "Sign contract"}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{data.customerSignedAt && (
|
||||||
|
<p className="mb-3 text-sm text-muted-foreground print:hidden">
|
||||||
|
Signed by {data.signerDisplayName} on{" "}
|
||||||
|
{new Date(data.customerSignedAt).toLocaleDateString()}.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<iframe
|
||||||
|
ref={iframeRef}
|
||||||
|
srcDoc={data.html}
|
||||||
|
className="w-full rounded-lg border bg-white shadow-sm"
|
||||||
|
style={{ minHeight: "80vh" }}
|
||||||
|
title="Last-mile contract document"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{signOpen && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4 print:hidden">
|
||||||
|
<div className="w-full max-w-md rounded-xl bg-background p-6 shadow-xl">
|
||||||
|
<h2 className="text-lg font-semibold">
|
||||||
|
{usingSaved ? "Approve signature" : "Sign contract"}
|
||||||
|
</h2>
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
|
Booking {data.bookingReference ?? data.bookingId} — signing issues
|
||||||
|
your advance invoice.
|
||||||
|
</p>
|
||||||
|
<div className="mt-4 space-y-3">
|
||||||
|
<label className="text-sm font-medium" htmlFor="lmSigner">
|
||||||
|
Full name
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="lmSigner"
|
||||||
|
className="w-full rounded-md border px-3 py-2 text-sm"
|
||||||
|
value={signerName}
|
||||||
|
onChange={(e) => setSignerName(e.target.value)}
|
||||||
|
/>
|
||||||
|
{usingSaved ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="overflow-hidden rounded-lg border-2 border-dashed border-border bg-white">
|
||||||
|
<img
|
||||||
|
src={savedSignatureImage ?? undefined}
|
||||||
|
alt="Saved signature"
|
||||||
|
className="mx-auto h-36 w-full object-contain"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="text-xs text-primary underline"
|
||||||
|
onClick={() => {
|
||||||
|
setDrawNew(true);
|
||||||
|
setSignatureData(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Draw a new signature instead
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ContractSignaturePad onChange={setSignatureData} />
|
||||||
|
)}
|
||||||
|
<label className="flex items-start gap-2 text-sm">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="mt-0.5"
|
||||||
|
checked={agreed}
|
||||||
|
onChange={(e) => setAgreed(e.target.checked)}
|
||||||
|
/>
|
||||||
|
<span>{CONSENT_TEXT}</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="mt-6 flex justify-end gap-2">
|
||||||
|
<Button variant="outline" onClick={() => setSignOpen(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
disabled={
|
||||||
|
signMutation.isPending ||
|
||||||
|
(!usingSaved && !signatureData) ||
|
||||||
|
!agreed ||
|
||||||
|
!signerName.trim()
|
||||||
|
}
|
||||||
|
onClick={confirmSign}
|
||||||
|
>
|
||||||
|
{usingSaved ? "Approve & sign" : "Confirm signature"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { isValidPhone, toEthiopianE164 } from "@/components/PhoneField";
|
import { ControlledPhoneField, isValidPhone } from "@/components/PhoneField";
|
||||||
import { api } from "@/services/api";
|
import { api } from "@/services/api";
|
||||||
import type {
|
import type {
|
||||||
CompanyProfileInput,
|
CompanyProfileInput,
|
||||||
@@ -30,6 +30,12 @@ import FaydaVerifyPanel from "@/components/FaydaVerifyPanel";
|
|||||||
import ETradeInfo, { type ETradeStatus } from "@/components/onboarding/ETradeInfo";
|
import ETradeInfo, { type ETradeStatus } from "@/components/onboarding/ETradeInfo";
|
||||||
import { ReadOnlyField } from "@/pages/accounts/companyProfileForm/ReadOnlyField";
|
import { ReadOnlyField } from "@/pages/accounts/companyProfileForm/ReadOnlyField";
|
||||||
import StepSection from "@/pages/accounts/companyProfileForm/StepSection";
|
import StepSection from "@/pages/accounts/companyProfileForm/StepSection";
|
||||||
|
import { ETRADE_BUNDLE_FIELDS as SHARED_ETRADE_FIELDS } from "@/pages/accounts/companyProfileForm/schema";
|
||||||
|
import {
|
||||||
|
firstValidEmail,
|
||||||
|
firstValidPhone,
|
||||||
|
normalizeIdentityPhones,
|
||||||
|
} from "@/pages/accounts/companyProfileForm/helpers";
|
||||||
|
|
||||||
export const COMPANY_PROFILE_SCHEMA = z.object({
|
export const COMPANY_PROFILE_SCHEMA = z.object({
|
||||||
companyName: z.string().min(1, "Company name is required"),
|
companyName: z.string().min(1, "Company name is required"),
|
||||||
@@ -43,12 +49,12 @@ export const COMPANY_PROFILE_SCHEMA = z.object({
|
|||||||
// no standalone input.
|
// no standalone input.
|
||||||
companyAddress: z.string().optional(),
|
companyAddress: z.string().optional(),
|
||||||
tinNumber: z.string().regex(/^\d{10}$/, "TIN must be exactly 10 digits"),
|
tinNumber: z.string().regex(/^\d{10}$/, "TIN must be exactly 10 digits"),
|
||||||
|
// Same rule as onboarding — the two forms write the same column, so they must
|
||||||
|
// not disagree about what is acceptable in it.
|
||||||
vatNumber: z
|
vatNumber: z
|
||||||
.string()
|
.string()
|
||||||
.trim()
|
.min(1, "VAT number is required")
|
||||||
.max(20, "VAT number is too long")
|
.regex(/^\d{10}$/, "VAT number must be exactly 10 digits"),
|
||||||
.optional()
|
|
||||||
.or(z.literal("")),
|
|
||||||
ownerPassportNumber: z.string().optional(),
|
ownerPassportNumber: z.string().optional(),
|
||||||
// Registration/address fields are eTrade-sourced — locked once eTrade
|
// Registration/address fields are eTrade-sourced — locked once eTrade
|
||||||
// supplies a value, editable only as an escape hatch when it doesn't
|
// supplies a value, editable only as an escape hatch when it doesn't
|
||||||
@@ -72,21 +78,13 @@ export const COMPANY_PROFILE_SCHEMA = z.object({
|
|||||||
|
|
||||||
export type CompanyProfileFormData = z.infer<typeof COMPANY_PROFILE_SCHEMA>;
|
export type CompanyProfileFormData = z.infer<typeof COMPANY_PROFILE_SCHEMA>;
|
||||||
|
|
||||||
/** UpdateProfilePayload keys eTrade owns — only resent when the customer re-verified them this session. */
|
/**
|
||||||
const ETRADE_BUNDLE_FIELDS = [
|
* `etradePhone` is not on this form, so the shared list is filtered down to the
|
||||||
"companyName",
|
* keys it actually holds. Source of truth: `companyProfileForm/schema.ts`.
|
||||||
"licenceNumber",
|
*/
|
||||||
"statusDescription",
|
const ETRADE_FIELDS = SHARED_ETRADE_FIELDS.filter(
|
||||||
"dateRegistered",
|
(k): k is Exclude<typeof k, "etradePhone"> => k !== "etradePhone",
|
||||||
"renewedFrom",
|
);
|
||||||
"renewalDate",
|
|
||||||
"renewedTo",
|
|
||||||
"region",
|
|
||||||
"zone",
|
|
||||||
"woreda",
|
|
||||||
"kebele",
|
|
||||||
"houseNo",
|
|
||||||
] as const satisfies readonly (keyof CompanyProfileFormData)[];
|
|
||||||
|
|
||||||
interface TabCompanyProfileProps {
|
interface TabCompanyProfileProps {
|
||||||
profile?: ProfileResponse;
|
profile?: ProfileResponse;
|
||||||
@@ -166,32 +164,39 @@ export default function TabCompanyProfile({
|
|||||||
values: defaultValues,
|
values: defaultValues,
|
||||||
});
|
});
|
||||||
|
|
||||||
const identity = profile?.identity;
|
// Fayda stores the phone as the national registry holds it (often a local
|
||||||
|
// number), which neither this form's E.164 validation nor the API's
|
||||||
|
// `@IsValidPhone()` accepts. Normalize on read — same as the wizard.
|
||||||
|
const identity = useMemo(
|
||||||
|
() => normalizeIdentityPhones(profile?.identity),
|
||||||
|
[profile?.identity],
|
||||||
|
);
|
||||||
const verifiedIdentity = identity?.faydaRequired === true;
|
const verifiedIdentity = identity?.faydaRequired === true;
|
||||||
|
|
||||||
// companyEmail/companyPhone are the owner's verified contact details, never
|
// companyEmail/companyPhone are the owner's verified contact details, never
|
||||||
// typed — same derivation as the onboarding wizard, just fed from the saved
|
// typed — same derivation as the onboarding wizard, just fed from the saved
|
||||||
// profile instead of an in-progress form.
|
// profile instead of an in-progress form. `firstValid*` rather than `??`:
|
||||||
useEffect(() => {
|
// these claims are optional AND unreliable — eTrade's registered phone is
|
||||||
if (!user) return;
|
// free text that arrives as things like "09 " — and `??` stops at the
|
||||||
setValue("companyEmail", identity?.owner.email ?? user.email ?? "", {
|
// first non-null, so junk became a read-only field the customer could not
|
||||||
shouldValidate: true,
|
// fix and a 400 on save. When nothing usable can be derived the fields below
|
||||||
});
|
// become editable instead of blocking.
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
const derivedEmail = firstValidEmail(identity?.owner.email, user?.email);
|
||||||
}, [identity?.owner.email, user?.email]);
|
const derivedPhone = firstValidPhone(
|
||||||
|
identity?.owner.phone,
|
||||||
|
profile?.etradePhone,
|
||||||
|
user?.phoneNumber,
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!user) return;
|
if (derivedEmail) setValue("companyEmail", derivedEmail);
|
||||||
setValue(
|
|
||||||
"companyPhone",
|
|
||||||
identity?.owner.phone ??
|
|
||||||
profile?.etradePhone ??
|
|
||||||
toEthiopianE164(user.phoneNumber) ??
|
|
||||||
"",
|
|
||||||
{ shouldValidate: true },
|
|
||||||
);
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [identity?.owner.phone, profile?.etradePhone, user?.phoneNumber]);
|
}, [derivedEmail]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (derivedPhone) setValue("companyPhone", derivedPhone);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [derivedPhone]);
|
||||||
|
|
||||||
// companyAddress is composed from the (locked) eTrade address parts, not
|
// companyAddress is composed from the (locked) eTrade address parts, not
|
||||||
// typed directly.
|
// typed directly.
|
||||||
@@ -249,7 +254,7 @@ export default function TabCompanyProfile({
|
|||||||
// on every save would otherwise trigger the server's eTrade
|
// on every save would otherwise trigger the server's eTrade
|
||||||
// authenticity re-check for no reason.
|
// authenticity re-check for no reason.
|
||||||
const etradeBundle: Record<string, string | undefined> = {};
|
const etradeBundle: Record<string, string | undefined> = {};
|
||||||
for (const key of ETRADE_BUNDLE_FIELDS) {
|
for (const key of ETRADE_FIELDS) {
|
||||||
if (dirtyFields[key]) etradeBundle[key] = data[key];
|
if (dirtyFields[key]) etradeBundle[key] = data[key];
|
||||||
}
|
}
|
||||||
if (dirtyFields.tinNumber) etradeBundle.tin = data.tinNumber;
|
if (dirtyFields.tinNumber) etradeBundle.tin = data.tinNumber;
|
||||||
@@ -296,13 +301,32 @@ export default function TabCompanyProfile({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const onSubmit = (data: CompanyProfileFormData) => {
|
const onSubmit = (data: CompanyProfileFormData) => {
|
||||||
|
setValidationError(null);
|
||||||
if (isCreate && selectedRoles.length === 0) return;
|
if (isCreate && selectedRoles.length === 0) return;
|
||||||
mutation.mutate(data);
|
mutation.mutate(data);
|
||||||
};
|
};
|
||||||
|
|
||||||
const saveErrorMessage = mutation.isError
|
/**
|
||||||
? extractApiError(mutation.error).message
|
* Without this, a failed validation made "Save Changes" a no-op: the fields
|
||||||
: null;
|
* the schema requires are largely eTrade-sourced and rendered read-only, so
|
||||||
|
* their error messages had nowhere to appear and the button simply did
|
||||||
|
* nothing. Name them instead.
|
||||||
|
*/
|
||||||
|
const [validationError, setValidationError] = useState<string | null>(null);
|
||||||
|
const onInvalid = (formErrors: typeof errors) => {
|
||||||
|
const messages = Object.values(formErrors)
|
||||||
|
.map((e) => e?.message)
|
||||||
|
.filter((m): m is string => Boolean(m));
|
||||||
|
setValidationError(
|
||||||
|
messages.length > 0
|
||||||
|
? `Please fix: ${[...new Set(messages)].join(", ")}.`
|
||||||
|
: "Some details are incomplete. Please review the fields above.",
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveErrorMessage =
|
||||||
|
validationError ??
|
||||||
|
(mutation.isError ? extractApiError(mutation.error).message : null);
|
||||||
|
|
||||||
const pendingOwnerReview = Boolean(
|
const pendingOwnerReview = Boolean(
|
||||||
(profile?.pendingChanges as { faydaIdentity?: Record<string, unknown> } | null)
|
(profile?.pendingChanges as { faydaIdentity?: Record<string, unknown> } | null)
|
||||||
@@ -333,7 +357,7 @@ export default function TabCompanyProfile({
|
|||||||
: "Your registration and identity come from eTrade and Fayda — re-verify to refresh them."}
|
: "Your registration and identity come from eTrade and Fayda — re-verify to refresh them."}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit(onSubmit)}>
|
<form onSubmit={handleSubmit(onSubmit, onInvalid)}>
|
||||||
<Stack gap="xl">
|
<Stack gap="xl">
|
||||||
<StepSection
|
<StepSection
|
||||||
index={1}
|
index={1}
|
||||||
@@ -341,9 +365,9 @@ export default function TabCompanyProfile({
|
|||||||
status={watch("vatNumber") ? "done" : "todo"}
|
status={watch("vatNumber") ? "done" : "todo"}
|
||||||
>
|
>
|
||||||
<TextInput
|
<TextInput
|
||||||
label="VAT Number (optional)"
|
label="VAT Number"
|
||||||
placeholder="e.g. 0012345678"
|
placeholder="e.g. 0012345678"
|
||||||
maxLength={20}
|
maxLength={10}
|
||||||
error={errors.vatNumber?.message}
|
error={errors.vatNumber?.message}
|
||||||
{...register("vatNumber")}
|
{...register("vatNumber")}
|
||||||
/>
|
/>
|
||||||
@@ -389,9 +413,33 @@ export default function TabCompanyProfile({
|
|||||||
{...register("ownerPassportNumber")}
|
{...register("ownerPassportNumber")}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{/* Read-only while the verified owner (or eTrade, or the account)
|
||||||
|
supplies them. Fayda's email/phone claims are optional, so
|
||||||
|
when nothing can be derived these become typeable — the API
|
||||||
|
requires both, and showing an empty read-only field is a save
|
||||||
|
that can never succeed. */}
|
||||||
<SimpleGrid cols={2} spacing="md">
|
<SimpleGrid cols={2} spacing="md">
|
||||||
<ReadOnlyField label="Company email" value={watch("companyEmail")} />
|
{derivedEmail ? (
|
||||||
<ReadOnlyField label="Company phone" value={watch("companyPhone")} />
|
<ReadOnlyField label="Company email" value={derivedEmail} />
|
||||||
|
) : (
|
||||||
|
<TextInput
|
||||||
|
label="Company Email"
|
||||||
|
type="email"
|
||||||
|
description="We couldn't find one on your verified identity or account — please enter it."
|
||||||
|
error={errors.companyEmail?.message}
|
||||||
|
{...register("companyEmail")}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{derivedPhone ? (
|
||||||
|
<ReadOnlyField label="Company phone" value={derivedPhone} />
|
||||||
|
) : (
|
||||||
|
<ControlledPhoneField
|
||||||
|
control={control}
|
||||||
|
name="companyPhone"
|
||||||
|
label="Company Phone"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
</StepSection>
|
</StepSection>
|
||||||
)}
|
)}
|
||||||
@@ -410,6 +458,7 @@ export default function TabCompanyProfile({
|
|||||||
error={errors.tinNumber?.message}
|
error={errors.tinNumber?.message}
|
||||||
onDataLoaded={handleETradeDataLoaded}
|
onDataLoaded={handleETradeDataLoaded}
|
||||||
onStatusChange={setTinStatus}
|
onStatusChange={setTinStatus}
|
||||||
|
alreadyVerified={hasRegistrationDetails}
|
||||||
/>
|
/>
|
||||||
{tinVerified && (
|
{tinVerified && (
|
||||||
<EtradeLockedCard
|
<EtradeLockedCard
|
||||||
@@ -520,7 +569,9 @@ function EtradeLockedCard({
|
|||||||
<ReadOnlyField label="Renewal Date" value={watch("renewalDate")} />
|
<ReadOnlyField label="Renewal Date" value={watch("renewalDate")} />
|
||||||
<ReadOnlyField label="Renewed From" value={watch("renewedFrom")} />
|
<ReadOnlyField label="Renewed From" value={watch("renewedFrom")} />
|
||||||
<ReadOnlyField label="Renewed To" value={watch("renewedTo")} />
|
<ReadOnlyField label="Renewed To" value={watch("renewedTo")} />
|
||||||
{region?.trim() ? (
|
{/* Membership of the catalog, not mere presence — a stored spelling
|
||||||
|
outside the list is otherwise uncorrectable. */}
|
||||||
|
{(ETHIOPIAN_REGIONS as readonly string[]).includes(region ?? "") ? (
|
||||||
<ReadOnlyField label="Region" value={region} />
|
<ReadOnlyField label="Region" value={region} />
|
||||||
) : (
|
) : (
|
||||||
<RegionSelect control={control} error={errors.region?.message} />
|
<RegionSelect control={control} error={errors.region?.message} />
|
||||||
@@ -548,7 +599,9 @@ function LockedField({
|
|||||||
errors: ReturnType<typeof useForm<CompanyProfileFormData>>["formState"]["errors"];
|
errors: ReturnType<typeof useForm<CompanyProfileFormData>>["formState"]["errors"];
|
||||||
}) {
|
}) {
|
||||||
const value = watch(name) as string | undefined;
|
const value = watch(name) as string | undefined;
|
||||||
if (value?.trim()) {
|
// A value that fails validation unlocks too — rendering a rejected value
|
||||||
|
// read-only is a save that can never succeed and never says why.
|
||||||
|
if (value?.trim() && !errors[name]) {
|
||||||
return <ReadOnlyField label={label} value={value} />;
|
return <ReadOnlyField label={label} value={value} />;
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
|
|||||||
122
apps/edr-freight-web/portal/src/pages/support/DocShell.tsx
Normal file
122
apps/edr-freight-web/portal/src/pages/support/DocShell.tsx
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
import { ArrowLeft, Train } from "lucide-react";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
|
||||||
|
import type { Section } from "./content";
|
||||||
|
|
||||||
|
/** Public pages reachable from every doc page's header and footer. */
|
||||||
|
const DOC_LINKS = [
|
||||||
|
{ to: "/help", label: "Help & Support" },
|
||||||
|
{ to: "/faq", label: "FAQ" },
|
||||||
|
{ to: "/privacy", label: "Privacy Policy" },
|
||||||
|
{ to: "/terms", label: "Terms of Service" },
|
||||||
|
];
|
||||||
|
|
||||||
|
interface DocShellProps {
|
||||||
|
title: string;
|
||||||
|
subtitle: string;
|
||||||
|
/** Rendered under the title, e.g. "Last updated 6 August 2026". */
|
||||||
|
meta?: string;
|
||||||
|
/** Path of the current page, so it is not linked to itself. */
|
||||||
|
current: string;
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Chrome shared by the help, FAQ and legal pages. These routes are public —
|
||||||
|
* the auth screens link to them before a session exists — so the shell carries
|
||||||
|
* its own header instead of relying on the authenticated app layout.
|
||||||
|
*/
|
||||||
|
export function DocShell({
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
meta,
|
||||||
|
current,
|
||||||
|
children,
|
||||||
|
}: DocShellProps) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-background text-foreground">
|
||||||
|
<header className="sticky top-0 z-50 border-b border-border bg-background/80 backdrop-blur-xl">
|
||||||
|
<div className="mx-auto flex max-w-4xl items-center justify-between gap-4 px-6 py-4">
|
||||||
|
<Link to="/" className="flex items-center gap-3">
|
||||||
|
<div className="flex size-10 items-center justify-center rounded-2xl bg-primary text-primary-foreground">
|
||||||
|
<Train className="size-5" />
|
||||||
|
</div>
|
||||||
|
<span className="font-bold">EDR Freight</span>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<Link
|
||||||
|
to="/portal"
|
||||||
|
className="inline-flex items-center gap-2 rounded-2xl border border-border px-4 py-2 text-sm font-semibold transition hover:bg-accent"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="size-4" />
|
||||||
|
Back to portal
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className="mx-auto max-w-4xl px-6 py-12">
|
||||||
|
<h1 className="text-4xl font-black tracking-tight">{title}</h1>
|
||||||
|
<p className="mt-4 text-lg leading-8 text-muted-foreground">
|
||||||
|
{subtitle}
|
||||||
|
</p>
|
||||||
|
{meta && (
|
||||||
|
<p className="mt-2 text-sm text-muted-foreground">{meta}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-10">{children}</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer className="border-t border-border py-8">
|
||||||
|
<div className="mx-auto flex max-w-4xl flex-col items-center justify-between gap-4 px-6 text-sm text-muted-foreground md:flex-row">
|
||||||
|
<span>© 2026 EDR Freight. All rights reserved.</span>
|
||||||
|
<nav className="flex flex-wrap items-center justify-center gap-x-6 gap-y-2">
|
||||||
|
{DOC_LINKS.filter((l) => l.to !== current).map((link) => (
|
||||||
|
<Link
|
||||||
|
key={link.to}
|
||||||
|
to={link.to}
|
||||||
|
className="font-semibold text-foreground transition-colors hover:text-primary"
|
||||||
|
>
|
||||||
|
{link.label}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Renders a legal document's numbered sections. */
|
||||||
|
export function DocSections({ sections }: { sections: Section[] }) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-10">
|
||||||
|
{sections.map((section) => (
|
||||||
|
<section key={section.heading}>
|
||||||
|
<h2 className="text-xl font-bold tracking-tight">
|
||||||
|
{section.heading}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{section.body?.map((paragraph) => (
|
||||||
|
<p
|
||||||
|
key={paragraph}
|
||||||
|
className="mt-4 leading-7 text-muted-foreground"
|
||||||
|
>
|
||||||
|
{paragraph}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{section.bullets && (
|
||||||
|
<ul className="mt-4 list-disc space-y-2 pl-5 leading-7 text-muted-foreground">
|
||||||
|
{section.bullets.map((bullet) => (
|
||||||
|
<li key={bullet}>{bullet}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default DocShell;
|
||||||
59
apps/edr-freight-web/portal/src/pages/support/FaqPage.tsx
Normal file
59
apps/edr-freight-web/portal/src/pages/support/FaqPage.tsx
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import { ChevronDown } from "lucide-react";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
|
||||||
|
import { DocShell } from "./DocShell";
|
||||||
|
import { FAQ_GROUPS, SUPPORT_CONTACT } from "./content";
|
||||||
|
|
||||||
|
export default function FaqPage() {
|
||||||
|
return (
|
||||||
|
<DocShell
|
||||||
|
current="/faq"
|
||||||
|
title="Frequently Asked Questions"
|
||||||
|
subtitle="Answers to the questions customers ask most about registering, booking cargo and settling invoices on EDR Freight."
|
||||||
|
>
|
||||||
|
<div className="space-y-10">
|
||||||
|
{FAQ_GROUPS.map((group) => (
|
||||||
|
<section key={group.title}>
|
||||||
|
<h2 className="text-xl font-bold tracking-tight">{group.title}</h2>
|
||||||
|
|
||||||
|
<div className="mt-4 space-y-3">
|
||||||
|
{group.items.map((item) => (
|
||||||
|
// Native disclosure: keyboard- and screen-reader-accessible
|
||||||
|
// without any state of our own.
|
||||||
|
<details
|
||||||
|
key={item.question}
|
||||||
|
className="group rounded-2xl border border-border bg-card px-5 py-4 transition hover:border-primary/40"
|
||||||
|
>
|
||||||
|
<summary className="flex cursor-pointer list-none items-center justify-between gap-4 font-semibold">
|
||||||
|
{item.question}
|
||||||
|
<ChevronDown className="size-5 shrink-0 text-muted-foreground transition-transform group-open:rotate-180" />
|
||||||
|
</summary>
|
||||||
|
|
||||||
|
<p className="mt-3 leading-7 text-muted-foreground">
|
||||||
|
{item.answer}
|
||||||
|
</p>
|
||||||
|
</details>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-12 rounded-[32px] border border-border bg-card p-8">
|
||||||
|
<h2 className="text-xl font-bold tracking-tight">
|
||||||
|
Still need a hand?
|
||||||
|
</h2>
|
||||||
|
<p className="mt-2 leading-7 text-muted-foreground">
|
||||||
|
Our team is on {SUPPORT_CONTACT.email} and {SUPPORT_CONTACT.phone}, or
|
||||||
|
you can start a chat from the support button inside the portal.
|
||||||
|
</p>
|
||||||
|
<Link
|
||||||
|
to="/help"
|
||||||
|
className="mt-6 inline-flex rounded-2xl bg-primary px-6 py-3 font-semibold text-primary-foreground transition hover:opacity-90"
|
||||||
|
>
|
||||||
|
Go to Help & Support
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</DocShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
183
apps/edr-freight-web/portal/src/pages/support/HelpPage.tsx
Normal file
183
apps/edr-freight-web/portal/src/pages/support/HelpPage.tsx
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
import {
|
||||||
|
Clock3,
|
||||||
|
FileText,
|
||||||
|
HelpCircle,
|
||||||
|
Mail,
|
||||||
|
MapPin,
|
||||||
|
MessageSquare,
|
||||||
|
Package,
|
||||||
|
Phone,
|
||||||
|
Receipt,
|
||||||
|
ShieldCheck,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
|
||||||
|
import { DocShell } from "./DocShell";
|
||||||
|
import { SUPPORT_CONTACT } from "./content";
|
||||||
|
|
||||||
|
const channels = [
|
||||||
|
{
|
||||||
|
icon: Mail,
|
||||||
|
title: "Email",
|
||||||
|
value: SUPPORT_CONTACT.email,
|
||||||
|
href: `mailto:${SUPPORT_CONTACT.email}`,
|
||||||
|
note: "Best for document issues and anything needing an attachment.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: Phone,
|
||||||
|
title: "Phone",
|
||||||
|
value: SUPPORT_CONTACT.phone,
|
||||||
|
href: `tel:${SUPPORT_CONTACT.phone.replace(/\s/g, "")}`,
|
||||||
|
note: "Best for urgent problems with cargo already in transit.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: MapPin,
|
||||||
|
title: "Head office",
|
||||||
|
value: SUPPORT_CONTACT.office,
|
||||||
|
note: "Walk-in support during working hours.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: Clock3,
|
||||||
|
title: "Support hours",
|
||||||
|
value: SUPPORT_CONTACT.hours,
|
||||||
|
note: "Outside these hours, email us and we reply the next working day.",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const topics = [
|
||||||
|
{
|
||||||
|
icon: ShieldCheck,
|
||||||
|
title: "Account & onboarding",
|
||||||
|
body: "Registering your company, uploading your trade licence and TIN, and getting an operational profile approved.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: FileText,
|
||||||
|
title: "Contracts",
|
||||||
|
body: "Requesting a freight contract, reviewing its terms and signing it with your saved signature and stamp.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: Package,
|
||||||
|
title: "Bookings & tracking",
|
||||||
|
body: "Raising a booking against a contract, adding last-mile transport and following the consignment along the corridor.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: Receipt,
|
||||||
|
title: "Invoices & payments",
|
||||||
|
body: "Finding invoices, paying through the bank channels and confirming a payment that has not yet settled.",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function HelpPage() {
|
||||||
|
return (
|
||||||
|
<DocShell
|
||||||
|
current="/help"
|
||||||
|
title="Help & Support"
|
||||||
|
subtitle="Get answers fast — browse the common topics, check the FAQ, or reach our team directly."
|
||||||
|
>
|
||||||
|
{/* Live chat is the fastest route, so lead with it. */}
|
||||||
|
<div className="rounded-[32px] border border-border bg-card p-8">
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<div className="rounded-2xl bg-accent p-3 text-primary">
|
||||||
|
<MessageSquare className="size-5" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-bold tracking-tight">
|
||||||
|
Chat with our team
|
||||||
|
</h2>
|
||||||
|
<p className="mt-2 leading-7 text-muted-foreground">
|
||||||
|
Signed-in customers can open a support conversation from the
|
||||||
|
headset button at the bottom right of every portal page. You can
|
||||||
|
send screenshots and documents in the chat, and replies appear
|
||||||
|
there and as a notification.
|
||||||
|
</p>
|
||||||
|
<Link
|
||||||
|
to="/portal"
|
||||||
|
className="mt-6 inline-flex rounded-2xl bg-primary px-6 py-3 font-semibold text-primary-foreground transition hover:opacity-90"
|
||||||
|
>
|
||||||
|
Open the portal
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section className="mt-12">
|
||||||
|
<h2 className="text-xl font-bold tracking-tight">Contact us</h2>
|
||||||
|
|
||||||
|
<div className="mt-4 grid gap-4 sm:grid-cols-2">
|
||||||
|
{channels.map((channel) => (
|
||||||
|
<div
|
||||||
|
key={channel.title}
|
||||||
|
className="flex items-start gap-4 rounded-2xl border border-border bg-background p-5"
|
||||||
|
>
|
||||||
|
<div className="rounded-2xl bg-accent p-3 text-primary">
|
||||||
|
<channel.icon className="size-5" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold">{channel.title}</p>
|
||||||
|
{channel.href ? (
|
||||||
|
<a
|
||||||
|
href={channel.href}
|
||||||
|
className="text-muted-foreground transition-colors hover:text-primary"
|
||||||
|
>
|
||||||
|
{channel.value}
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<p className="text-muted-foreground">{channel.value}</p>
|
||||||
|
)}
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
|
{channel.note}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="mt-12">
|
||||||
|
<h2 className="text-xl font-bold tracking-tight">Common topics</h2>
|
||||||
|
|
||||||
|
<div className="mt-4 grid gap-4 sm:grid-cols-2">
|
||||||
|
{topics.map((topic) => (
|
||||||
|
<Link
|
||||||
|
key={topic.title}
|
||||||
|
to="/faq"
|
||||||
|
className="rounded-2xl border border-border bg-background p-5 transition hover:border-primary/40"
|
||||||
|
>
|
||||||
|
<div className="inline-flex rounded-2xl bg-accent p-3 text-primary">
|
||||||
|
<topic.icon className="size-5" />
|
||||||
|
</div>
|
||||||
|
<p className="mt-4 font-semibold">{topic.title}</p>
|
||||||
|
<p className="mt-1 leading-7 text-muted-foreground">
|
||||||
|
{topic.body}
|
||||||
|
</p>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="mt-12 rounded-[32px] border border-border bg-card p-8">
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<div className="rounded-2xl bg-accent p-3 text-primary">
|
||||||
|
<HelpCircle className="size-5" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-bold tracking-tight">
|
||||||
|
What to include when you contact us
|
||||||
|
</h2>
|
||||||
|
<ul className="mt-3 list-disc space-y-2 pl-5 leading-7 text-muted-foreground">
|
||||||
|
<li>Your company name and the email you sign in with.</li>
|
||||||
|
<li>
|
||||||
|
The reference of the contract, booking or invoice involved.
|
||||||
|
</li>
|
||||||
|
<li>What you expected to happen and what happened instead.</li>
|
||||||
|
<li>A screenshot of any error message the portal showed.</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</DocShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { DocSections, DocShell } from "./DocShell";
|
||||||
|
import { LEGAL_LAST_UPDATED, PRIVACY_SECTIONS } from "./content";
|
||||||
|
|
||||||
|
export default function PrivacyPolicyPage() {
|
||||||
|
return (
|
||||||
|
<DocShell
|
||||||
|
current="/privacy"
|
||||||
|
title="Privacy Policy"
|
||||||
|
subtitle="How EDR Freight collects, uses, shares and protects the information you provide when you use the platform."
|
||||||
|
meta={`Last updated ${LEGAL_LAST_UPDATED}`}
|
||||||
|
>
|
||||||
|
<DocSections sections={PRIVACY_SECTIONS} />
|
||||||
|
</DocShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
15
apps/edr-freight-web/portal/src/pages/support/TermsPage.tsx
Normal file
15
apps/edr-freight-web/portal/src/pages/support/TermsPage.tsx
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { DocSections, DocShell } from "./DocShell";
|
||||||
|
import { LEGAL_LAST_UPDATED, TERMS_SECTIONS } from "./content";
|
||||||
|
|
||||||
|
export default function TermsPage() {
|
||||||
|
return (
|
||||||
|
<DocShell
|
||||||
|
current="/terms"
|
||||||
|
title="Terms of Service"
|
||||||
|
subtitle="The terms on which EDR provides the EDR Freight platform and the freight services you request through it."
|
||||||
|
meta={`Last updated ${LEGAL_LAST_UPDATED}`}
|
||||||
|
>
|
||||||
|
<DocSections sections={TERMS_SECTIONS} />
|
||||||
|
</DocShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
358
apps/edr-freight-web/portal/src/pages/support/content.ts
Normal file
358
apps/edr-freight-web/portal/src/pages/support/content.ts
Normal file
@@ -0,0 +1,358 @@
|
|||||||
|
/**
|
||||||
|
* Copy for the public help/FAQ/legal pages. Kept as data so the pages stay
|
||||||
|
* thin — the shell in `DocShell.tsx` renders any `Section[]` the same way.
|
||||||
|
*
|
||||||
|
* The privacy and terms text is the platform's working draft; legal counsel
|
||||||
|
* signs off on the final wording, and `LEGAL_LAST_UPDATED` is bumped with it.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const SUPPORT_CONTACT = {
|
||||||
|
email: "support@edrfreight.com",
|
||||||
|
phone: "+251 11 000 0000",
|
||||||
|
office: "Addis Ababa, Ethiopia",
|
||||||
|
hours: "Monday – Saturday, 8:30 AM – 5:30 PM (EAT)",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const LEGAL_LAST_UPDATED = "6 August 2026";
|
||||||
|
|
||||||
|
export interface Section {
|
||||||
|
heading: string;
|
||||||
|
/** Paragraphs, rendered in order. */
|
||||||
|
body?: string[];
|
||||||
|
/** Optional bullet list, rendered after the paragraphs. */
|
||||||
|
bullets?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FaqItem {
|
||||||
|
question: string;
|
||||||
|
answer: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FaqGroup {
|
||||||
|
title: string;
|
||||||
|
items: FaqItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FAQ_GROUPS: FaqGroup[] = [
|
||||||
|
{
|
||||||
|
title: "Getting started",
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
question: "How do I open an account on EDR Freight?",
|
||||||
|
answer:
|
||||||
|
"Sign up with your work email and verify the one-time code we send you. After you set a password, the onboarding wizard collects your company details, trade licence, TIN certificate and the operational services you need (importer, exporter, freight forwarder or transporter). Submit the wizard and our team reviews the application.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
question: "How long does account approval take?",
|
||||||
|
answer:
|
||||||
|
"Most complete applications are reviewed within two working days. You will see the status on your dashboard, and we email you when a profile is approved or when a document needs to be re-uploaded.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
question: "My profile was rejected. What now?",
|
||||||
|
answer:
|
||||||
|
"The rejection notice states the reason. Open Settings, correct the details or replace the document that was flagged, and re-apply — you do not need to create a new account.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
question: "Can one company hold several operational services?",
|
||||||
|
answer:
|
||||||
|
"Yes. A company can hold importer, exporter, freight forwarder and transporter profiles at the same time. Each is approved separately, and the header lets you switch between the ones you hold.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Contracts and bookings",
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
question: "What is the difference between a contract and a booking?",
|
||||||
|
answer:
|
||||||
|
"A contract is the commercial agreement covering a cargo movement — route, commodity, volume and rates. A booking is a single shipment executed under that contract. You create the contract once, then raise bookings against it for each consignment.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
question: "How do I create a booking?",
|
||||||
|
answer:
|
||||||
|
"Open the contract from the Contracts list and choose New Booking. Provide the consignment details, containers or tonnage, and the last-mile requirement if you need one. Bookings can also be started from the Bookings page, which routes you through contract selection first.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
question: "Why do I have to sign a contract before shipping?",
|
||||||
|
answer:
|
||||||
|
"The contract document is the binding agreement for the movement. You must scroll to the end, accept the terms, and sign it with your saved signature and stamp before EDR schedules any wagon against it.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
question: "Where do I set up my signature and stamp?",
|
||||||
|
answer:
|
||||||
|
"Under Signature & Stamp in the portal. It is saved once and reused for every contract you sign, so you do not have to upload it per document.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
question: "Can I change a booking after submitting it?",
|
||||||
|
answer:
|
||||||
|
"You can edit a booking while it is still pending review. Once EDR has confirmed it and allocated capacity, changes go through our operations team — contact support with the booking reference.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
question: "How do I track a consignment?",
|
||||||
|
answer:
|
||||||
|
"Open the booking and use the tracking panel, which shows the current milestone, the wagon or container assigned, and the timestamps recorded at each corridor point.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Invoices and payments",
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
question: "Where do I find my invoices?",
|
||||||
|
answer:
|
||||||
|
"The Invoices page lists every invoice raised against your company, with its status, due date and outstanding balance. Open any invoice to see its line items and download a PDF copy.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
question: "Which payment methods are supported?",
|
||||||
|
answer:
|
||||||
|
"Payments are made through the integrated bank channels shown at checkout. After you complete the payment on the bank's page you are returned to the portal, and the invoice status updates once the bank confirms the transaction.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
question: "My payment was deducted but the invoice still shows unpaid.",
|
||||||
|
answer:
|
||||||
|
"Bank confirmations can lag by a few minutes. Use the Check Payment Status page linked from your receipt; if it still has not settled after an hour, email support with the invoice number and the bank reference and we will reconcile it.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
question: "Why is my invoice amount rounded?",
|
||||||
|
answer:
|
||||||
|
"Some bank channels only accept whole-birr amounts, so invoices routed through them are rounded up to the nearest birr. The rounding is shown on the invoice detail page.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Account and security",
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
question: "How do I reset my password?",
|
||||||
|
answer:
|
||||||
|
"Use Forgot Password on the sign-in page. We email you a reset link that is valid for a limited time. If a member of our staff issued the link, it works the same way even if you are already signed in.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
question: "Can I add colleagues to my company account?",
|
||||||
|
answer:
|
||||||
|
"Yes. Company administrators can invite additional users from Settings. Each user signs in with their own credentials, and actions are recorded against the individual who performed them.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
question: "How do I update company details after approval?",
|
||||||
|
answer:
|
||||||
|
"Edit them in Settings. Changes to regulated fields — trade licence, TIN, legal name — are re-verified by our team before they take effect.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const PRIVACY_SECTIONS: Section[] = [
|
||||||
|
{
|
||||||
|
heading: "1. Introduction",
|
||||||
|
body: [
|
||||||
|
"The Ethio-Djibouti Standard Gauge Rail Share Company (\"EDR\", \"we\", \"us\") operates the EDR Freight platform, which lets customers register their business, agree freight contracts, raise bookings, track consignments and settle invoices online.",
|
||||||
|
"This policy explains what personal and business information we collect through the platform, why we collect it, how long we keep it and what rights you have over it. It applies to the EDR Freight customer portal and the services reached through it.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
heading: "2. Information we collect",
|
||||||
|
body: [
|
||||||
|
"We collect information you give us, information generated by your use of the platform, and information we receive from the regulators and financial institutions we work with.",
|
||||||
|
],
|
||||||
|
bullets: [
|
||||||
|
"Account details — name, work email address, phone number and the credentials used to sign in.",
|
||||||
|
"Company and compliance records — legal name, trade licence, TIN certificate, VAT registration, ownership and manager details, and the operational services you apply for.",
|
||||||
|
"Identity verification data — where you verify through a national identity service, the verification result and the attributes that service returns to us.",
|
||||||
|
"Operational data — contracts, bookings, consignment and cargo details, container and wagon assignments, tracking events and delivery confirmations.",
|
||||||
|
"Financial data — invoices, payment references, transaction status and settlement confirmations received from banks. We do not store your card numbers or online banking credentials.",
|
||||||
|
"Support data — the messages and files you send us through the in-app support chat or by email.",
|
||||||
|
"Technical data — IP address, device and browser information, and event logs generated when you use the platform.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
heading: "3. How we use your information",
|
||||||
|
bullets: [
|
||||||
|
"To create and administer your account and verify that your company is entitled to the services it applies for.",
|
||||||
|
"To perform the freight contracts and bookings you place, including allocating capacity and coordinating rail and last-mile movements.",
|
||||||
|
"To issue invoices, process payments and keep the accounting records the law requires us to keep.",
|
||||||
|
"To provide customer support and respond to the questions and complaints you raise.",
|
||||||
|
"To keep the platform secure, detect misuse and investigate incidents.",
|
||||||
|
"To meet our legal, tax, customs and regulatory obligations in Ethiopia and Djibouti.",
|
||||||
|
"To improve the platform — measuring which features are used and where users encounter errors, using aggregated and pseudonymised data wherever that is sufficient.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
heading: "4. Legal basis for processing",
|
||||||
|
body: [
|
||||||
|
"We process your information because it is necessary to perform the contract between you and EDR, because we have a legal obligation to do so (customs, tax and transport regulation), or because we have a legitimate interest in operating and securing the platform. Where we rely on your consent — for example, optional marketing messages — you can withdraw it at any time.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
heading: "5. Sharing your information",
|
||||||
|
body: [
|
||||||
|
"We do not sell your information. We share it only where it is necessary to deliver the service or where the law requires it.",
|
||||||
|
],
|
||||||
|
bullets: [
|
||||||
|
"Government and regulatory bodies — customs, revenue and transport authorities in Ethiopia and Djibouti, to the extent required for the movement of your cargo.",
|
||||||
|
"Ports, terminals and last-mile transporters involved in executing your bookings.",
|
||||||
|
"Banks and payment providers, to initiate and reconcile the payments you make.",
|
||||||
|
"Technology suppliers who host and maintain the platform on our behalf, under contracts that restrict them to processing data on our instructions.",
|
||||||
|
"Courts, law enforcement and other authorities where we are legally compelled to disclose.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
heading: "6. International transfers",
|
||||||
|
body: [
|
||||||
|
"Cross-border freight inherently involves parties in more than one country, so consignment and clearance information is shared with counterparties and authorities in Djibouti as well as Ethiopia. Where we transfer information outside Ethiopia, we do so only as far as the movement requires or the law permits, and we require recipients to protect it to a comparable standard.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
heading: "7. Data retention",
|
||||||
|
body: [
|
||||||
|
"We keep account and company records for as long as your account is active. Contract, booking, customs and financial records are kept for the period required by Ethiopian commercial, tax and customs law after the relevant transaction, because we are obliged to be able to produce them. Support conversations and technical logs are kept for a shorter period, sufficient to resolve disputes and investigate security incidents.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
heading: "8. Security",
|
||||||
|
body: [
|
||||||
|
"Access to the platform requires authentication, and staff access to customer records is limited to what each role needs. Data is transmitted over encrypted connections and stored on systems protected by access controls and logging. No system is perfectly secure, so please keep your credentials confidential and tell us immediately if you believe your account has been compromised.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
heading: "9. Your rights",
|
||||||
|
body: [
|
||||||
|
"Subject to Ethiopian law, you may ask us to give you a copy of the personal information we hold about you, correct it if it is inaccurate, restrict or object to certain processing, or delete it where we are not required to keep it. Requests are handled through the contact details below; we may need to verify your identity before acting.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
heading: "10. Cookies and similar technologies",
|
||||||
|
body: [
|
||||||
|
"The platform uses cookies and browser storage to keep you signed in, remember your interface preferences and measure how the product is used so we can fix problems. Essential cookies cannot be turned off without breaking sign-in. You can clear or block the rest through your browser settings.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
heading: "11. Children",
|
||||||
|
body: [
|
||||||
|
"The platform is a business service and is not directed at children. We do not knowingly collect information from anyone under 18.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
heading: "12. Changes to this policy",
|
||||||
|
body: [
|
||||||
|
"We may update this policy as the platform and the law change. Material changes are announced in the portal before they take effect, and the date at the top of this page always reflects the current version.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
heading: "13. Contact us",
|
||||||
|
body: [
|
||||||
|
`Questions about this policy or about how we handle your information can be sent to ${SUPPORT_CONTACT.email}, called in on ${SUPPORT_CONTACT.phone}, or addressed to our head office in ${SUPPORT_CONTACT.office}.`,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const TERMS_SECTIONS: Section[] = [
|
||||||
|
{
|
||||||
|
heading: "1. These terms",
|
||||||
|
body: [
|
||||||
|
"These terms govern your use of the EDR Freight platform operated by the Ethio-Djibouti Standard Gauge Rail Share Company (\"EDR\"). By creating an account or using the platform, the company you represent agrees to them.",
|
||||||
|
"The platform is the channel through which you register, request and manage freight services. The commercial terms of each movement — routes, rates, volumes and payment terms — are set out in the freight contract you sign in the platform. Where a signed contract and these terms conflict, the signed contract governs that movement.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
heading: "2. Eligibility and accounts",
|
||||||
|
bullets: [
|
||||||
|
"The platform is for registered businesses. You confirm that you are authorised to act for the company you register and to bind it to these terms.",
|
||||||
|
"The information and documents you submit — trade licence, TIN, VAT registration, ownership details — must be accurate, current and genuine.",
|
||||||
|
"Accounts and operational profiles are activated only after EDR has reviewed and approved them, and approval may be refused or withdrawn.",
|
||||||
|
"You are responsible for keeping credentials confidential and for everything done under your account. Tell us at once if you suspect unauthorised use.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
heading: "3. Contracts and bookings",
|
||||||
|
bullets: [
|
||||||
|
"A freight contract takes effect when it is signed in the platform by you and countersigned by EDR.",
|
||||||
|
"A booking is a request for a specific movement under a contract. It becomes binding when EDR confirms it and allocates capacity — submission alone does not reserve a wagon or container.",
|
||||||
|
"You are responsible for the accuracy of consignment data: commodity description, weight, dimensions, container numbers, hazardous classification and consignee details.",
|
||||||
|
"Capacity is finite. EDR may decline, defer or reschedule a booking where capacity, safety, operating conditions or regulatory direction require it.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
heading: "4. Cargo, documents and compliance",
|
||||||
|
bullets: [
|
||||||
|
"You must obtain and provide every permit, customs declaration and clearance document the movement requires, and you warrant that the cargo may lawfully be carried.",
|
||||||
|
"Prohibited and restricted goods may not be tendered without EDR's prior written agreement and any licence the law requires.",
|
||||||
|
"Cargo must be packed, secured and, where applicable, labelled to the standard the mode of carriage requires. EDR may inspect, refuse or offload cargo that is misdeclared or unsafe.",
|
||||||
|
"You are liable for fines, demurrage, storage charges and losses arising from misdeclared cargo, missing documents or delays attributable to you.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
heading: "5. Rates, invoicing and payment",
|
||||||
|
bullets: [
|
||||||
|
"Charges are calculated from the rates in your contract, the tariffs published in the platform, and any accessorial services actually rendered.",
|
||||||
|
"Invoices are issued in the platform and are payable by the due date shown on them, through the payment channels the platform offers.",
|
||||||
|
"Payment is confirmed when the funds are confirmed by the bank, not when payment is initiated.",
|
||||||
|
"Overdue amounts may attract interest and may result in suspension of new bookings or of the account until the balance is cleared.",
|
||||||
|
"Taxes and statutory duties are your responsibility unless the contract expressly says otherwise.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
heading: "6. Delivery, delay and liability",
|
||||||
|
body: [
|
||||||
|
"Transit times shown in the platform are estimates based on planned schedules. They are not guarantees, and EDR is not liable for indirect or consequential loss, loss of profit or loss of market arising from delay.",
|
||||||
|
"EDR's liability for loss of or damage to cargo is limited to the extent set out in the applicable freight contract and in the transport law governing the carriage. Claims must be notified in writing within the period the contract specifies; late claims may be rejected.",
|
||||||
|
"Neither party is liable for failure to perform caused by events beyond its reasonable control, including natural disasters, industrial action, civil unrest, infrastructure failure, or acts of government and regulatory authorities.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
heading: "7. Acceptable use of the platform",
|
||||||
|
bullets: [
|
||||||
|
"Use the platform only for its intended purpose and in accordance with applicable law.",
|
||||||
|
"Do not attempt to gain unauthorised access, probe or disrupt the service, or interfere with other customers' data.",
|
||||||
|
"Do not scrape, resell or redistribute platform content, rates or data without written permission.",
|
||||||
|
"Do not upload malware or content that infringes the rights of others.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
heading: "8. Electronic signatures and records",
|
||||||
|
body: [
|
||||||
|
"You agree that contracts signed in the platform using your stored signature and stamp are validly executed, that the records the platform keeps of those signatures are admissible evidence of them, and that they carry the same effect as signatures on paper.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
heading: "9. Availability and changes to the service",
|
||||||
|
body: [
|
||||||
|
"We aim to keep the platform available, but it may be interrupted for maintenance, upgrades or reasons outside our control. We may add, change or withdraw features. Where a change materially affects how you use the platform, we will give reasonable notice in the portal.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
heading: "10. Suspension and termination",
|
||||||
|
body: [
|
||||||
|
"We may suspend or terminate access where these terms are breached, where documents prove to be false, where amounts remain unpaid, or where the law or a regulator requires it. You may stop using the platform at any time. Termination does not affect obligations already incurred — cargo in transit, invoices outstanding, or records we are required to retain.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
heading: "11. Intellectual property",
|
||||||
|
body: [
|
||||||
|
"The platform, its software, design and content belong to EDR or its licensors. You are granted a non-exclusive, non-transferable right to use it for your own freight operations. Your commercial and consignment data remains yours; you grant us the right to process it as needed to deliver the service and as described in the Privacy Policy.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
heading: "12. Confidentiality and data protection",
|
||||||
|
body: [
|
||||||
|
"Each party will keep the other's non-public commercial information confidential and use it only for the purposes of the services. Our handling of personal information is described in the Privacy Policy, which forms part of these terms.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
heading: "13. Governing law and disputes",
|
||||||
|
body: [
|
||||||
|
"These terms are governed by the laws of the Federal Democratic Republic of Ethiopia. The parties will first attempt to resolve any dispute amicably; failing that, the dispute is subject to the jurisdiction of the competent courts of Ethiopia, without prejudice to any arbitration clause agreed in a specific freight contract.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
heading: "14. Changes to these terms",
|
||||||
|
body: [
|
||||||
|
"We may update these terms as the service and the law change. Updates are published here and announced in the portal. Continuing to use the platform after an update takes effect means you accept the revised terms.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
heading: "15. Contact",
|
||||||
|
body: [
|
||||||
|
`For questions about these terms, write to ${SUPPORT_CONTACT.email} or call ${SUPPORT_CONTACT.phone}.`,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { URL_CONSTANTS } from "@/constants/URLS";
|
||||||
|
import { client } from "../utils/api";
|
||||||
|
|
||||||
|
const L = URL_CONSTANTS.LAST_MILE_REQUESTS;
|
||||||
|
|
||||||
|
export interface LastMileRequest {
|
||||||
|
id: string;
|
||||||
|
bookingId: string;
|
||||||
|
booking?: { id: string; reference?: string } | null;
|
||||||
|
trainScheduleId: string;
|
||||||
|
status: "AWAITING_CONFIRMATION" | "SUBMITTED" | "APPROVED" | "REJECTED";
|
||||||
|
requestedContainerNumbers?: string[] | null;
|
||||||
|
requestedDeliveryDate?: string | null;
|
||||||
|
customerSignedAt?: string | null;
|
||||||
|
rejectionReason?: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LastMileContractView {
|
||||||
|
requestId: string;
|
||||||
|
bookingId: string;
|
||||||
|
bookingReference?: string | null;
|
||||||
|
status: LastMileRequest["status"];
|
||||||
|
html: string;
|
||||||
|
customerSignedAt: string | null;
|
||||||
|
signerDisplayName: string | null;
|
||||||
|
canSign: boolean;
|
||||||
|
savedSignature?: {
|
||||||
|
signerDisplayName: string;
|
||||||
|
signatureImageUrl: string | null;
|
||||||
|
stampImageUrl: string | null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SignLastMileContractPayload {
|
||||||
|
signatureImageBase64?: string;
|
||||||
|
signerDisplayName: string;
|
||||||
|
consentText?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const lastMileRequestsService = {
|
||||||
|
/** One last-mile confirmation request, by id. */
|
||||||
|
get: async (id: string): Promise<LastMileRequest> => {
|
||||||
|
const { data } = await client.get(L.BY_ID(id));
|
||||||
|
return data.data ?? data;
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Confirm which containers go via EDR last-mile and the requested delivery date. */
|
||||||
|
submit: async (
|
||||||
|
id: string,
|
||||||
|
containerNumbers: string[],
|
||||||
|
deliveryDate: string,
|
||||||
|
): Promise<LastMileRequest> => {
|
||||||
|
const { data } = await client.post(L.SUBMIT(id), { containerNumbers, deliveryDate });
|
||||||
|
return data.data ?? data;
|
||||||
|
},
|
||||||
|
|
||||||
|
/** LM contract view model + rendered HTML + saved signature. */
|
||||||
|
getContractView: async (id: string): Promise<LastMileContractView> => {
|
||||||
|
const { data } = await client.get(L.CONTRACT_VIEW(id));
|
||||||
|
return data.data ?? data;
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Agree & sign the LM contract — the advance invoice is issued right after. */
|
||||||
|
signContract: async (
|
||||||
|
id: string,
|
||||||
|
payload: SignLastMileContractPayload,
|
||||||
|
): Promise<LastMileRequest> => {
|
||||||
|
const { data } = await client.post(L.CONTRACT_SIGN(id), payload);
|
||||||
|
return data.data ?? data;
|
||||||
|
},
|
||||||
|
|
||||||
|
/** The LM contract PDF (LM_<CustomerName>.pdf). */
|
||||||
|
downloadContractDocument: async (id: string): Promise<Blob> => {
|
||||||
|
const { data } = await client.get(L.CONTRACT_DOCUMENT(id), {
|
||||||
|
responseType: "blob",
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -34,6 +34,16 @@ function humanizeApiMessage(raw: string): string {
|
|||||||
return raw;
|
return raw;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* NestJS's ValidationPipe reports every failed constraint at once, so `message`
|
||||||
|
* arrives as a string[] rather than a string. Flatten it — passing the array
|
||||||
|
* through left the UI rendering its entries run together with no separator.
|
||||||
|
*/
|
||||||
|
function asMessage(value: unknown): string {
|
||||||
|
if (Array.isArray(value)) return value.filter(Boolean).join(". ");
|
||||||
|
return typeof value === "string" ? value : "";
|
||||||
|
}
|
||||||
|
|
||||||
export function extractApiError(err: unknown): ApiError {
|
export function extractApiError(err: unknown): ApiError {
|
||||||
if (err && typeof err === "object") {
|
if (err && typeof err === "object") {
|
||||||
const obj = err as Record<string, unknown>;
|
const obj = err as Record<string, unknown>;
|
||||||
@@ -41,13 +51,10 @@ export function extractApiError(err: unknown): ApiError {
|
|||||||
if (response) {
|
if (response) {
|
||||||
const statusCode = response.status as number | undefined;
|
const statusCode = response.status as number | undefined;
|
||||||
const data = response.data as Record<string, unknown> | undefined;
|
const data = response.data as Record<string, unknown> | undefined;
|
||||||
|
const raw = asMessage(data?.message) || asMessage(data?.error);
|
||||||
return {
|
return {
|
||||||
code: (data?.message as string) || (data?.error as string) || "api_error",
|
code: raw || "api_error",
|
||||||
message: humanizeApiMessage(
|
message: humanizeApiMessage(raw || "An unexpected error occurred"),
|
||||||
(data?.message as string) ||
|
|
||||||
(data?.error as string) ||
|
|
||||||
"An unexpected error occurred",
|
|
||||||
),
|
|
||||||
statusCode,
|
statusCode,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
import { loadEnv } from "vite";
|
||||||
import { defineConfig } from "vitest/config";
|
import { defineConfig } from "vitest/config";
|
||||||
import react from "@vitejs/plugin-react";
|
import react from "@vitejs/plugin-react";
|
||||||
import tailwindcss from "@tailwindcss/vite";
|
import tailwindcss from "@tailwindcss/vite";
|
||||||
@@ -13,26 +14,30 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|||||||
const mantineCore = path.resolve(__dirname, "node_modules/@mantine/core");
|
const mantineCore = path.resolve(__dirname, "node_modules/@mantine/core");
|
||||||
const mantineHooks = path.resolve(__dirname, "node_modules/@mantine/hooks");
|
const mantineHooks = path.resolve(__dirname, "node_modules/@mantine/hooks");
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig(({ mode }) => {
|
||||||
plugins: [react(), tailwindcss()],
|
const env = loadEnv(mode, __dirname, "");
|
||||||
resolve: {
|
|
||||||
alias: {
|
return {
|
||||||
"@": path.resolve(__dirname, "./src"),
|
plugins: [react(), tailwindcss()],
|
||||||
// Resolve from TS source so Vite gets ESM named exports (dist is CommonJS).
|
resolve: {
|
||||||
"@edr/types": path.resolve(__dirname, "../../../packages/types/src/index.ts"),
|
alias: {
|
||||||
"@mantine/core": mantineCore,
|
"@": path.resolve(__dirname, "./src"),
|
||||||
"@mantine/hooks": mantineHooks,
|
// Resolve from TS source so Vite gets ESM named exports (dist is CommonJS).
|
||||||
|
"@edr/types": path.resolve(__dirname, "../../../packages/types/src/index.ts"),
|
||||||
|
"@mantine/core": mantineCore,
|
||||||
|
"@mantine/hooks": mantineHooks,
|
||||||
|
},
|
||||||
|
dedupe: ["react", "react-dom", "@mantine/core", "@mantine/hooks"],
|
||||||
},
|
},
|
||||||
dedupe: ["react", "react-dom", "@mantine/core", "@mantine/hooks"],
|
optimizeDeps: {
|
||||||
},
|
include: ["@mantine/core", "@mantine/hooks", "@edr/ui-common"],
|
||||||
optimizeDeps: {
|
},
|
||||||
include: ["@mantine/core", "@mantine/hooks", "@edr/ui-common"],
|
server: {
|
||||||
},
|
port: Number(env.PORT) || 5273,
|
||||||
server: {
|
host: "0.0.0.0",
|
||||||
port: 5173,
|
},
|
||||||
host: "0.0.0.0",
|
test: {
|
||||||
},
|
environment: "node",
|
||||||
test: {
|
},
|
||||||
environment: "node",
|
};
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
/** Maximum time (hours) a passenger has to pay after booking. */
|
/** Maximum time (hours) a passenger has to pay after booking. */
|
||||||
export const MAX_PAYMENT_HOURS = 2;
|
export const MAX_PAYMENT_HOURS = 240;
|
||||||
/** Minutes before departure: cutoff for new bookings and payment deadline. */
|
/** Minutes before departure: cutoff for new bookings and payment deadline. */
|
||||||
export const CUTOFF_MINUTES = 30;
|
export const CUTOFF_MINUTES = 30;
|
||||||
|
|
||||||
|
|||||||
@@ -152,6 +152,12 @@ export class BookingsController {
|
|||||||
@ApiQuery({ name: "returnLegStatus", required: false })
|
@ApiQuery({ name: "returnLegStatus", required: false })
|
||||||
@ApiQuery({ name: "bookingType", required: false })
|
@ApiQuery({ name: "bookingType", required: false })
|
||||||
@ApiQuery({ name: "paymentStatus", required: false })
|
@ApiQuery({ name: "paymentStatus", required: false })
|
||||||
|
@ApiQuery({
|
||||||
|
name: "providerTxnId",
|
||||||
|
required: false,
|
||||||
|
description:
|
||||||
|
"Payment provider transaction / order / merchant reference (partial, case-insensitive)",
|
||||||
|
})
|
||||||
@ApiQuery({ name: "dateFrom", required: false })
|
@ApiQuery({ name: "dateFrom", required: false })
|
||||||
@ApiQuery({ name: "dateTo", required: false })
|
@ApiQuery({ name: "dateTo", required: false })
|
||||||
@ApiQuery({ name: "page", required: false })
|
@ApiQuery({ name: "page", required: false })
|
||||||
@@ -162,6 +168,7 @@ export class BookingsController {
|
|||||||
@Query("returnLegStatus") returnLegStatus?: string,
|
@Query("returnLegStatus") returnLegStatus?: string,
|
||||||
@Query("bookingType") bookingType?: string,
|
@Query("bookingType") bookingType?: string,
|
||||||
@Query("paymentStatus") paymentStatus?: string,
|
@Query("paymentStatus") paymentStatus?: string,
|
||||||
|
@Query("providerTxnId") providerTxnId?: string,
|
||||||
@Query("dateFrom") dateFrom?: string,
|
@Query("dateFrom") dateFrom?: string,
|
||||||
@Query("dateTo") dateTo?: string,
|
@Query("dateTo") dateTo?: string,
|
||||||
@Query("page") page?: string,
|
@Query("page") page?: string,
|
||||||
@@ -173,6 +180,7 @@ export class BookingsController {
|
|||||||
returnLegStatus,
|
returnLegStatus,
|
||||||
bookingType,
|
bookingType,
|
||||||
paymentStatus,
|
paymentStatus,
|
||||||
|
providerTxnId,
|
||||||
dateFrom,
|
dateFrom,
|
||||||
dateTo,
|
dateTo,
|
||||||
page: page ? parseInt(page) : 1,
|
page: page ? parseInt(page) : 1,
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user