diff --git a/README.md b/README.md
index 125561b9d..50e0b55f6 100644
--- a/README.md
+++ b/README.md
@@ -369,7 +369,7 @@ pnpm --filter @edr/passenger-api run prisma:seed
- 3 User accounts (Admin, Passenger, Agent)
- Fare rules for ADULT and CHILD passenger categories
- Currency exchange rates (ETB, DJF, USD)
-- Baggage allowance rules
+- Luggage allowance rules
- Notification templates
- Promotions and FAQ content
- Menu items and station crowd signals
diff --git a/apps/edr-freight-api/src/migrations/2000000000000-CreateCompanyChangeRequest.ts b/apps/edr-freight-api/src/migrations/2000000000000-CreateCompanyChangeRequest.ts
new file mode 100644
index 000000000..9d8d2b5c9
--- /dev/null
+++ b/apps/edr-freight-api/src/migrations/2000000000000-CreateCompanyChangeRequest.ts
@@ -0,0 +1,60 @@
+import { MigrationInterface, QueryRunner, Table, TableIndex } from 'typeorm';
+
+/**
+ * Staging table for customer profile edits that require backoffice review. An
+ * already-approved company's settings edits are snapshotted here (Pending)
+ * instead of being written to the live `companies` row; a reviewer approves
+ * (snapshot applied) or rejects with a note (customer amends & resubmits).
+ */
+export class CreateCompanyChangeRequest2000000000000
+ implements MigrationInterface
+{
+ name = 'CreateCompanyChangeRequest2000000000000';
+
+ public async up(queryRunner: QueryRunner): Promise {
+ await queryRunner.createTable(
+ new Table({
+ schema: 'freight',
+ name: 'company_change_request',
+ columns: [
+ { name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'gen_random_uuid()' },
+ { name: 'company_id', type: 'uuid' },
+ { name: 'snapshot', type: 'jsonb' },
+ { name: 'documents', type: 'jsonb', isNullable: true },
+ { name: 'status', type: 'varchar', length: '20', default: "'pending'" },
+ { name: 'note', type: 'text', isNullable: true },
+ { name: 'submitted_by', type: 'uuid', isNullable: true },
+ { name: 'submitted_at', type: 'timestamptz', isNullable: true },
+ { name: 'reviewed_by', type: 'uuid', isNullable: true },
+ { name: 'reviewed_at', type: 'timestamptz', isNullable: true },
+ { name: 'created_at', type: 'timestamptz', default: 'now()' },
+ { name: 'updated_at', type: 'timestamptz', default: 'now()' },
+ { name: 'deleted_at', type: 'timestamptz', isNullable: true },
+ ],
+ foreignKeys: [
+ {
+ columnNames: ['company_id'],
+ referencedSchema: 'freight',
+ referencedTableName: 'companies',
+ referencedColumnNames: ['id'],
+ onDelete: 'CASCADE',
+ },
+ ],
+ }),
+ true,
+ );
+
+ await queryRunner.createIndex(
+ 'freight.company_change_request',
+ new TableIndex({ name: 'idx_company_change_request_company', columnNames: ['company_id'] }),
+ );
+ await queryRunner.createIndex(
+ 'freight.company_change_request',
+ new TableIndex({ name: 'idx_company_change_request_status', columnNames: ['status'] }),
+ );
+ }
+
+ public async down(queryRunner: QueryRunner): Promise {
+ await queryRunner.dropTable('freight.company_change_request', true);
+ }
+}
diff --git a/apps/edr-freight-api/src/migrations/2000000000001-AddCompanyProfileReview.ts b/apps/edr-freight-api/src/migrations/2000000000001-AddCompanyProfileReview.ts
new file mode 100644
index 000000000..2c2fdf3e1
--- /dev/null
+++ b/apps/edr-freight-api/src/migrations/2000000000001-AddCompanyProfileReview.ts
@@ -0,0 +1,28 @@
+import { MigrationInterface, QueryRunner, TableColumn } from 'typeorm';
+
+/**
+ * Adds reviewer note/id/timestamp to company_profiles so a rejected operational
+ * role (new ProfileStatus 'rejected') can carry the reason back to the customer,
+ * who can then amend and reapply.
+ */
+export class AddCompanyProfileReview2000000000001
+ implements MigrationInterface
+{
+ name = 'AddCompanyProfileReview2000000000001';
+
+ public async up(queryRunner: QueryRunner): Promise {
+ await queryRunner.addColumns('freight.company_profiles', [
+ new TableColumn({ name: 'review_note', type: 'text', isNullable: true }),
+ new TableColumn({ name: 'reviewed_by', type: 'uuid', isNullable: true }),
+ new TableColumn({ name: 'reviewed_at', type: 'timestamptz', isNullable: true }),
+ ]);
+ }
+
+ public async down(queryRunner: QueryRunner): Promise {
+ await queryRunner.dropColumns('freight.company_profiles', [
+ 'review_note',
+ 'reviewed_by',
+ 'reviewed_at',
+ ]);
+ }
+}
diff --git a/apps/edr-freight-api/src/migrations/2020000000000-WarehouseCapacityKgToTons.ts b/apps/edr-freight-api/src/migrations/2020000000000-WarehouseCapacityKgToTons.ts
new file mode 100644
index 000000000..333a0f541
--- /dev/null
+++ b/apps/edr-freight-api/src/migrations/2020000000000-WarehouseCapacityKgToTons.ts
@@ -0,0 +1,35 @@
+import { MigrationInterface, QueryRunner } from 'typeorm';
+
+/**
+ * Weights are tonnes everywhere. Warehouse / yard / zone capacity was stored in
+ * kg (e.g. 25000, 5000, 2500) — convert existing rows to tonnes (÷1000). Cargo
+ * weight (warehouse_inventory.weight ← cargo_total_weight_vgm) is already tonnes
+ * and is NOT touched; truck gross weight has no data yet. Runs exactly once
+ * (tracked by TypeORM) — re-running would divide again.
+ */
+export class WarehouseCapacityKgToTons2020000000000 implements MigrationInterface {
+ name = 'WarehouseCapacityKgToTons2020000000000';
+
+ private readonly tables = ['warehouses', 'warehouse_yards', 'warehouse_zones'];
+ private readonly columns = ['capacity_weight', 'current_weight', 'max_weight'];
+
+ public async up(queryRunner: QueryRunner): Promise {
+ for (const table of this.tables) {
+ for (const column of this.columns) {
+ await queryRunner.query(
+ `UPDATE freight.${table} SET ${column} = ${column} / 1000.0 WHERE ${column} IS NOT NULL`,
+ );
+ }
+ }
+ }
+
+ public async down(queryRunner: QueryRunner): Promise {
+ for (const table of this.tables) {
+ for (const column of this.columns) {
+ await queryRunner.query(
+ `UPDATE freight.${table} SET ${column} = ${column} * 1000.0 WHERE ${column} IS NOT NULL`,
+ );
+ }
+ }
+ }
+}
diff --git a/apps/edr-freight-api/src/migrations/2040000000000-MigrateLicenseFilesToFileRecords.ts b/apps/edr-freight-api/src/migrations/2040000000000-MigrateLicenseFilesToFileRecords.ts
new file mode 100644
index 000000000..d3720de33
--- /dev/null
+++ b/apps/edr-freight-api/src/migrations/2040000000000-MigrateLicenseFilesToFileRecords.ts
@@ -0,0 +1,59 @@
+import { MigrationInterface, QueryRunner } from "typeorm";
+
+/**
+ * Business-license files used to live inline as a jsonb array on
+ * `company_profiles.business_license_files`. They now belong to the FileRecord
+ * model (`freight.files`, resource `company_profiles`, code `business_license`)
+ * so they get stable ids and stream through `GET /api/files/:id` — the same
+ * proxy path regular documents use — instead of broken direct-MinIO URLs.
+ *
+ * This copies each existing inline entry into `freight.files` by reference
+ * (keeping the stored object URL — no bytes are re-uploaded). The original jsonb
+ * column is left intact for rollback safety.
+ */
+export class MigrateLicenseFilesToFileRecords2040000000000
+ implements MigrationInterface
+{
+ name = "MigrateLicenseFilesToFileRecords2040000000000";
+
+ public async up(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(`
+ INSERT INTO freight.files
+ (id, resource_id, resource, code, name, url, size, mime_type, created_at, updated_at)
+ SELECT
+ gen_random_uuid(),
+ cp.id,
+ 'company_profiles',
+ 'business_license',
+ COALESCE(elem->>'name', 'license'),
+ elem->>'url',
+ COALESCE(NULLIF(elem->>'size', '')::int, 0),
+ COALESCE(NULLIF(elem->>'mimeType', ''), 'application/octet-stream'),
+ now(),
+ now()
+ FROM freight.company_profiles cp
+ CROSS JOIN LATERAL jsonb_array_elements(cp.business_license_files) AS elem
+ WHERE cp.business_license_files IS NOT NULL
+ AND jsonb_typeof(cp.business_license_files) = 'array'
+ AND elem->>'url' IS NOT NULL
+ AND NOT EXISTS (
+ SELECT 1 FROM freight.files f
+ WHERE f.resource_id = cp.id
+ AND f.resource = 'company_profiles'
+ AND f.code = 'business_license'
+ AND f.url = elem->>'url'
+ AND f.deleted_at IS NULL
+ );
+ `);
+ }
+
+ public async down(queryRunner: QueryRunner): Promise {
+ // Reverse the model migration by dropping the license FileRecords. The
+ // original jsonb column was never cleared, so the data still exists there.
+ await queryRunner.query(`
+ DELETE FROM freight.files
+ WHERE resource = 'company_profiles'
+ AND code = 'business_license';
+ `);
+ }
+}
diff --git a/apps/edr-freight-api/src/migrations/2050000000000-AddCustomerTruckContainerLoadedAt.ts b/apps/edr-freight-api/src/migrations/2050000000000-AddCustomerTruckContainerLoadedAt.ts
new file mode 100644
index 000000000..332df2423
--- /dev/null
+++ b/apps/edr-freight-api/src/migrations/2050000000000-AddCustomerTruckContainerLoadedAt.ts
@@ -0,0 +1,35 @@
+import { MigrationInterface, QueryRunner } from 'typeorm';
+
+/**
+ * Adds customer_truck_containers.loaded_at so an assignment (customer planning
+ * which containers ride which truck) is distinct from the container actually
+ * being loaded. Stage LOADED now requires loaded_at; customer assignment alone
+ * keeps the container at its prior stage (RECEIVED/GRN) with its planned truck
+ * shown. Backfills containers on already-departed trucks (they left loaded).
+ */
+export class AddCustomerTruckContainerLoadedAt2050000000000
+ implements MigrationInterface
+{
+ public async up(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(`
+ ALTER TABLE freight.customer_truck_containers
+ ADD COLUMN IF NOT EXISTS loaded_at TIMESTAMPTZ;
+ `);
+
+ await queryRunner.query(`
+ UPDATE freight.customer_truck_containers ctc
+ SET loaded_at = a.departed_at
+ FROM freight.customer_truck_assignments a
+ WHERE a.id = ctc.assignment_id
+ AND a.departed_at IS NOT NULL
+ AND ctc.deleted_at IS NULL
+ AND ctc.loaded_at IS NULL;
+ `);
+ }
+
+ public async down(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(`
+ ALTER TABLE freight.customer_truck_containers DROP COLUMN IF EXISTS loaded_at;
+ `);
+ }
+}
diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts
index 444ac578a..f556751fb 100644
--- a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts
+++ b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts
@@ -68,6 +68,8 @@ import { AddCustomerTruckDto } from './dto/add-customer-truck.dto';
import { DepartCustomerTruckDto } from './dto/depart-customer-truck.dto';
import { LoadCustomerTruckDto } from './dto/load-customer-truck.dto';
import { CustomerTruckService } from './customer-truck.service';
+import { FirstMileService } from '../first-mile/first-mile.service';
+import { LastMileService } from '../last-mile/last-mile.service';
import { GenerateGrnDto } from './dto/generate-grn.dto';
import { ContainerReceiptService } from './container-receipt.service';
import { SignContractDto } from './dto/sign-contract.dto';
@@ -81,6 +83,60 @@ import {
hasFreightPermission,
} from "../../common/freight-permission.util";
+interface MileVehicleSummary {
+ plate: string | null;
+ code: string | null;
+ driverName: string | null;
+ containerNumber: string | null;
+ distanceKm: number | null;
+}
+
+interface MileLegSummary {
+ status: string;
+ exactKm: number | null;
+ remainingPayment: number | null;
+ currency: string;
+ invoiced: boolean;
+ vehicles: MileVehicleSummary[];
+}
+
+/** Trim a first/last-mile record down to a customer-safe operational summary. */
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+function summarizeMileLeg(rec?: Record): MileLegSummary | null {
+ if (!rec) return null;
+ const num = (v: unknown) => (v == null ? null : Number(v));
+ const assignments: Array> = rec.vehicleAssignments ?? []; // eslint-disable-line @typescript-eslint/no-explicit-any
+ const currency =
+ rec.vehicle?.currency ??
+ assignments[0]?.vehicle?.currency ??
+ rec.booking?.paymentCurrency ??
+ 'ETB';
+ const vehicles: MileVehicleSummary[] = assignments.map((a) => ({
+ plate: a.vehicle?.plateNumber ?? null,
+ code: a.vehicle?.code ?? null,
+ driverName: a.vehicle?.assignedDriverName ?? null,
+ containerNumber: a.containerNumber ?? null,
+ distanceKm: num(a.distanceKm),
+ }));
+ if (!vehicles.length && rec.vehicle) {
+ vehicles.push({
+ plate: rec.vehicle.plateNumber ?? null,
+ code: rec.vehicle.code ?? null,
+ driverName: rec.vehicle.assignedDriverName ?? null,
+ containerNumber: null,
+ distanceKm: num(rec.exactKm),
+ });
+ }
+ return {
+ status: rec.status ?? '',
+ exactKm: num(rec.exactKm),
+ remainingPayment: num(rec.remainingPayment),
+ currency,
+ invoiced: Boolean(rec.invoice),
+ vehicles,
+ };
+}
+
@ApiTags("bookings")
@Controller("bookings")
@ApiBearerAuth()
@@ -94,6 +150,8 @@ export class BookingsController {
private readonly bookingClearanceService: BookingClearanceService,
private readonly customerTruckService: CustomerTruckService,
private readonly containerReceiptService: ContainerReceiptService,
+ private readonly firstMileService: FirstMileService,
+ private readonly lastMileService: LastMileService,
) {}
@Post()
@@ -290,6 +348,33 @@ export class BookingsController {
return this.transitionService.enrichBookingResponse(booking);
}
+ @Get(':id/mile-summary')
+ @ApiOperation({
+ summary: 'First/last-mile operational summary for a booking (customer-safe)',
+ })
+ async mileSummary(
+ @Param('id', ParseUUIDPipe) id: string,
+ @CurrentUser() user: TCurrentUser,
+ ) {
+ // Customers may only see their own booking's mile summary.
+ const booking = await this.bookingsService.findById(id);
+ if (
+ !hasFreightPermission(user, FREIGHT_PERMS.bookings.view) &&
+ !hasFreightPermission(user, FREIGHT_PERMS.bookings.clearanceView)
+ ) {
+ await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking);
+ }
+
+ const [first, last] = await Promise.all([
+ this.firstMileService.findAll({ bookingId: id, pageSize: 1 }),
+ this.lastMileService.findAll({ bookingId: id, pageSize: 1 }),
+ ]);
+ return {
+ firstMile: summarizeMileLeg(first.data[0]),
+ lastMile: summarizeMileLeg(last.data[0]),
+ };
+ }
+
@Post(':id/customer-truck-assignment')
@ApiOperation({ summary: 'Customer assigns external truck and driver for terminal pickup' })
async assignCustomerTruck(
@@ -350,6 +435,21 @@ export class BookingsController {
return this.customerTruckService.addTruck(id, dto);
}
+ @Patch(':id/customer-trucks/:assignmentId')
+ @ApiOperation({ summary: 'Edit a not-yet-arrived customer truck (plate/driver/type + containers)' })
+ async updateCustomerTruck(
+ @Param('id', ParseUUIDPipe) id: string,
+ @Param('assignmentId', ParseUUIDPipe) assignmentId: string,
+ @Body() dto: AddCustomerTruckDto,
+ @CurrentUser() user: TCurrentUser,
+ ) {
+ const booking = await this.bookingsService.findById(id);
+ if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
+ await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking);
+ }
+ return this.customerTruckService.updateTruck(id, assignmentId, dto);
+ }
+
@Delete(':id/customer-trucks/:assignmentId')
@ApiOperation({ summary: 'Remove a not-yet-arrived customer truck from a booking' })
async removeCustomerTruck(
diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.module.ts b/apps/edr-freight-api/src/modules/bookings/bookings.module.ts
index 48c5b628b..4b3e3bcca 100644
--- a/apps/edr-freight-api/src/modules/bookings/bookings.module.ts
+++ b/apps/edr-freight-api/src/modules/bookings/bookings.module.ts
@@ -12,6 +12,7 @@ import { FileUploadSettingsModule } from '../file-upload-settings/file-upload-se
import { SignaturesModule } from '../signatures/signatures.module';
import { BillingModule } from '../billing/billing.module';
import { FirstMileModule } from '../first-mile/first-mile.module';
+import { LastMileModule } from '../last-mile/last-mile.module';
import { BookingContractService } from './booking-contract.service';
import { BookingInvoiceService } from './booking-invoice.service';
// import { BookingPaymentController } from './booking-payment.controller';
@@ -70,6 +71,7 @@ import { VehiclesModule } from "../vehicles/vehicles.module";
NotificationsModule,
NotificationInboxModule,
forwardRef(() => FirstMileModule),
+ forwardRef(() => LastMileModule),
forwardRef(() => TrainSchedulingModule),
forwardRef(() => ContractsModule),
forwardRef(() => ContractsModule),
diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts
index a5f25ad21..48a29988a 100644
--- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts
+++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts
@@ -1424,6 +1424,20 @@ export class BookingsService {
schedule?.status ?? null;
}
+ // A generated-but-unsigned SELF_HAUL handover means the customer must approve
+ // delivery from the portal (booking-based, one per booking). EDR last-mile
+ // handovers are per delivering truck and signed by the receiver at the door,
+ // so they never surface the portal "Approve delivery" action.
+ const [pendingHandover] = await this.dataSource.query(
+ `SELECT 1 FROM freight.booking_handovers
+ WHERE booking_id = $1 AND signed_at IS NULL AND deleted_at IS NULL
+ AND mile_type = 'SELF_HAUL'
+ LIMIT 1`,
+ [id],
+ );
+ (booking as Booking & { handoverAwaitingSignature?: boolean }).handoverAwaitingSignature =
+ Boolean(pendingHandover);
+
return booking;
}
diff --git a/apps/edr-freight-api/src/modules/bookings/customer-truck.service.ts b/apps/edr-freight-api/src/modules/bookings/customer-truck.service.ts
index 6f0ec6f55..4e364a03a 100644
--- a/apps/edr-freight-api/src/modules/bookings/customer-truck.service.ts
+++ b/apps/edr-freight-api/src/modules/bookings/customer-truck.service.ts
@@ -42,21 +42,30 @@ export class CustomerTruckService {
const booking = await this.loadBookingGuard(bookingId);
this.assertSelfHaulPaid(booking);
- const isExport = booking.tradeDirection === 'EXPORT';
const requested = (dto.containerNumbers ?? []).map((n) => n.trim().toUpperCase());
- // EXPORT: the truck delivers 1–2 known containers. IMPORT: containers are
- // not pre-specified — they are registered + weighed when the truck leaves.
- if (isExport) {
- if (requested.length < 1 || requested.length > 2) {
- throw new BadRequestException('An export truck must carry 1 or 2 of the booking containers');
- }
- } else if (requested.length > 2) {
+ // Both import and export specify the containers each truck carries. Capacity
+ // is size-based: a 40ft container fills the truck (max 1); two 20ft containers
+ // fit (max 2), no size mixing. #trucks <= #containers follows naturally since
+ // each container is assigned to exactly one truck.
+ if (requested.length < 1) {
+ throw new BadRequestException('Select at least one container for this truck');
+ }
+ if (requested.length > 2) {
throw new BadRequestException('A truck carries at most 2 containers');
}
if (requested.length) {
const bookingNumbers = await this.bookingContainerNumbers(bookingId);
+ // Never assign more trucks than the booking has containers.
+ const existingTrucks = await this.dataSource
+ .getRepository(CustomerTruckAssignment)
+ .count({ where: { bookingId } });
+ if (existingTrucks + 1 > bookingNumbers.length) {
+ throw new BadRequestException(
+ `Cannot assign more trucks than containers — this booking has ${bookingNumbers.length} container(s) and ${existingTrucks} truck(s) already assigned.`,
+ );
+ }
for (const n of requested) {
if (!bookingNumbers.includes(n)) {
throw new BadRequestException(`Container ${n} is not one of this booking's containers`);
@@ -68,6 +77,13 @@ export class CustomerTruckService {
throw new ConflictException(`Container ${n} is already loaded onto another truck`);
}
}
+ // Size cap: a 40ft container fills the truck.
+ const sizes = await this.containerSizes(bookingId, requested);
+ if (sizes.some((s) => s.includes('40')) && requested.length > 1) {
+ throw new BadRequestException(
+ 'A 40ft container fills the truck — assign only 1 container to this truck',
+ );
+ }
}
await this.dataSource.transaction(async (manager) => {
@@ -133,6 +149,76 @@ export class CustomerTruckService {
return this.listTrucks(bookingId);
}
+ /**
+ * Edit a truck assignment — plate/driver/type and the containers it carries.
+ * Allowed only until the truck has arrived (same guard as removal). Container
+ * rules mirror {@link addTruck}: 1–2 of the booking's containers, none already
+ * on another truck, and a 40ft container fills the truck (max 1).
+ */
+ async updateTruck(
+ bookingId: string,
+ assignmentId: string,
+ dto: AddCustomerTruckDto,
+ ): Promise {
+ const booking = await this.loadBookingGuard(bookingId);
+ this.assertSelfHaulPaid(booking);
+
+ const assignment = await this.assignments.findByIdWithContainers(assignmentId);
+ if (!assignment || assignment.bookingId !== bookingId) {
+ throw new NotFoundException('Truck assignment not found for this booking');
+ }
+ if (assignment.arrivedAt) {
+ throw new ConflictException('Cannot edit a truck that has already arrived');
+ }
+
+ const requested = (dto.containerNumbers ?? []).map((n) => n.trim().toUpperCase());
+ if (requested.length < 1) {
+ throw new BadRequestException('Select at least one container for this truck');
+ }
+ if (requested.length > 2) {
+ throw new BadRequestException('A truck carries at most 2 containers');
+ }
+ const bookingNumbers = await this.bookingContainerNumbers(bookingId);
+ for (const n of requested) {
+ if (!bookingNumbers.includes(n)) {
+ throw new BadRequestException(`Container ${n} is not one of this booking's containers`);
+ }
+ }
+ // Exclude THIS truck's own containers so re-saving the same set is allowed.
+ const assignedElsewhere = await this.assignedContainerNumbersExcept(bookingId, assignmentId);
+ for (const n of requested) {
+ if (assignedElsewhere.includes(n)) {
+ throw new ConflictException(`Container ${n} is already loaded onto another truck`);
+ }
+ }
+ const sizes = await this.containerSizes(bookingId, requested);
+ if (sizes.some((s) => s.includes('40')) && requested.length > 1) {
+ throw new BadRequestException(
+ 'A 40ft container fills the truck — assign only 1 container to this truck',
+ );
+ }
+
+ await this.dataSource.transaction(async (manager) => {
+ await manager.getRepository(CustomerTruckAssignment).update(assignmentId, {
+ plateNumber: dto.truckPlateNumber.trim().toUpperCase(),
+ driverName: dto.driverName.trim(),
+ truckType: dto.truckType.trim(),
+ });
+ await manager.getRepository(CustomerTruckContainer).softDelete({ assignmentId });
+ await manager.getRepository(CustomerTruckContainer).save(
+ requested.map((containerNumber) =>
+ manager.getRepository(CustomerTruckContainer).create({
+ assignmentId,
+ bookingId,
+ containerNumber,
+ }),
+ ),
+ );
+ });
+
+ return this.listTrucks(bookingId);
+ }
+
/**
* Register an IMPORT self-haul truck leaving the port: the containers it
* actually loaded (replacing any provisional list) and its weighed gross.
@@ -226,6 +312,13 @@ export class CustomerTruckService {
if (assignment.departedAt) {
throw new ConflictException('This truck has already left — its load is locked');
}
+ // Containers can only be loaded after the truck has physically arrived at the
+ // warehouse (arrival weighing recorded). Assignment alone is just planning.
+ if (!assignment.arrivedAt) {
+ throw new BadRequestException(
+ 'Record the truck arrival before loading — containers can only be loaded onto an arrived truck',
+ );
+ }
const requested = (dto.containerNumbers ?? []).map((n) => n.trim().toUpperCase());
if (!requested.length) {
@@ -244,30 +337,35 @@ export class CustomerTruckService {
}
}
- const grossKg = await this.vgmKgForContainers(bookingId, requested);
+ const grossTons = await this.vgmTonsForContainers(bookingId, requested);
await this.dataSource.transaction(async (manager) => {
await manager.getRepository(CustomerTruckContainer).softDelete({ assignmentId });
+ // Operator loading the truck: stamp loaded_at so these containers move to
+ // the LOADED stage (customer assignment alone leaves loaded_at null).
+ const loadedAt = new Date();
await manager.getRepository(CustomerTruckContainer).save(
requested.map((containerNumber) =>
manager.getRepository(CustomerTruckContainer).create({
assignmentId,
bookingId,
containerNumber,
+ loadedAt,
}),
),
);
- // Provisional gross from the loaded containers' VGM — overridden by the
- // weighed gross on departure.
+ // Provisional gross (tonnes) from the loaded containers' VGM — overridden
+ // by the weighed gross on departure. (Column is *_kg but holds tonnes.)
await manager.getRepository(CustomerTruckAssignment).update(assignmentId, {
- grossWeightKg: grossKg,
+ grossWeightKg: grossTons,
});
});
return this.listTrucks(bookingId);
}
- private async vgmKgForContainers(bookingId: string, numbers: string[]): Promise {
- const [row]: Array<{ kg: string }> = await this.dataSource.query(
- `SELECT COALESCE(SUM(bcu.vgm_tons), 0) * 1000 AS kg
+ /** Summed VGM (tonnes) of the given containers — provisional truck gross. */
+ private async vgmTonsForContainers(bookingId: string, numbers: string[]): Promise {
+ const [row]: Array<{ tons: string }> = await this.dataSource.query(
+ `SELECT COALESCE(SUM(bcu.vgm_tons), 0) AS tons
FROM freight.booking_container_units bcu
JOIN freight.booking_container bc
ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL
@@ -276,7 +374,7 @@ export class CustomerTruckService {
AND bcu.deleted_at IS NULL`,
[bookingId, numbers],
);
- return Number(row?.kg ?? 0);
+ return Number(row?.tons ?? 0);
}
/**
@@ -399,4 +497,20 @@ export class CustomerTruckService {
);
return rows.map((r) => r.containerNumber.trim().toUpperCase());
}
+
+ /** Contract container sizes (e.g. "20ft" / "40ft") for the given container numbers. */
+ private async containerSizes(bookingId: string, numbers: string[]): Promise {
+ if (!numbers.length) return [];
+ const rows: Array<{ size: string | null }> = await this.dataSource.query(
+ `SELECT bc.container_size AS "size"
+ 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 UPPER(bcu.container_number) = ANY($2)
+ AND bcu.deleted_at IS NULL`,
+ [bookingId, numbers],
+ );
+ return rows.map((r) => (r.size ?? '').trim());
+ }
}
diff --git a/apps/edr-freight-api/src/modules/bookings/entities/customer-truck-container.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/customer-truck-container.entity.ts
index 110e31671..8b6ecc8b9 100644
--- a/apps/edr-freight-api/src/modules/bookings/entities/customer-truck-container.entity.ts
+++ b/apps/edr-freight-api/src/modules/bookings/entities/customer-truck-container.entity.ts
@@ -23,4 +23,12 @@ export class CustomerTruckContainer extends BaseEntity {
@Column({ name: 'container_number', type: 'varchar', length: 64 })
containerNumber!: string;
+
+ /**
+ * When the container was actually loaded onto the truck by the operator.
+ * Null = customer-assigned (planned) but not yet loaded. Stage LOADED requires
+ * this to be set, so customer assignment alone does not mark a container loaded.
+ */
+ @Column({ name: 'loaded_at', type: 'timestamptz', nullable: true })
+ loadedAt?: Date | null;
}
diff --git a/apps/edr-freight-api/src/modules/companies/companies.controller.ts b/apps/edr-freight-api/src/modules/companies/companies.controller.ts
index b1761b35f..f8fbb26b0 100644
--- a/apps/edr-freight-api/src/modules/companies/companies.controller.ts
+++ b/apps/edr-freight-api/src/modules/companies/companies.controller.ts
@@ -12,6 +12,7 @@ import {
HttpStatus,
UseInterceptors,
UploadedFiles,
+ BadRequestException,
} from "@nestjs/common";
import { AnyFilesInterceptor } from "@nestjs/platform-express";
import { ApiOperation, ApiTags, ApiConsumes } from "@nestjs/swagger";
@@ -33,7 +34,7 @@ import {
ResponseCompanyDto,
ResponseCompanyProfileDto,
} from "./dto/response-company.dto";
-import { BusinessLicenseFile } from "./entities/company-profile.entity";
+import { ProfileLicenseFileView } from "./entities/company-profile.entity";
import { ResponseExternalProfileDto } from "./dto/response-external-profile.dto";
import { CompanyInfoResponseDto } from "./dto/company-info-response.dto";
import { UpdateProfileDto } from "./dto/update-profile.dto";
@@ -43,6 +44,8 @@ import { ListCompaniesQueryDto } from "./dto/list-companies-query.dto";
import { CompanyStatsResponseDto } from "./dto/company-stats-response.dto";
import { OnboardingRequirementsResponseDto } from "./dto/onboarding-requirements-response.dto";
import { UpdateCompanyProfileStatusDto } from "./dto/update-company-profile-status.dto";
+import { RejectChangeRequestDto } from "./dto/reject-change-request.dto";
+import { ChangeRequestResponseDto } from "./dto/change-request-response.dto";
import { FetchETradeDto } from "./dto/fetch-etrade.dto";
import { ETradeResponseDto } from "./dto/etrade-response.dto";
@@ -61,6 +64,25 @@ export class CompaniesController {
private readonly filesService: FilesService,
) { }
+ /**
+ * License files are FileRecord-backed and previewed through `GET /api/files/:id`
+ * (the client builds that URL from the returned `id`). Populate each profile
+ * DTO's `licenseFiles` with its live/pending files in one batched lookup.
+ */
+ private async populateLicenseFiles(
+ companyId: string,
+ profiles: { id: string; licenseFiles: ProfileLicenseFileView[] }[],
+ ): Promise {
+ if (profiles.length === 0) return;
+ const byProfile = await this.companiesService.assembleLicenseFilesByProfile(
+ companyId,
+ profiles.map((p) => p.id),
+ );
+ for (const p of profiles) {
+ p.licenseFiles = byProfile[p.id] ?? [];
+ }
+ }
+
@Get("getInfo")
@ApiOperation({ summary: "Get company info for the current user" })
async getInfo(
@@ -68,7 +90,10 @@ export class CompaniesController {
): Promise {
const { profile, company } =
await this.companiesService.getCompanyInfoByUserId(user.id);
- return new CompanyInfoResponseDto(profile, company);
+ const review = await this.companiesService.getOpenChangeRequestForCompany(
+ company.id,
+ );
+ return new CompanyInfoResponseDto(profile, company, review);
}
@Get("profile")
@@ -78,7 +103,42 @@ export class CompaniesController {
): Promise {
const { profile, company } =
await this.companiesService.getCompanyInfoByUserId(user.id);
- return new ProfileResponseDto(profile, company);
+ const review = await this.companiesService.getOpenChangeRequestForCompany(
+ company.id,
+ );
+ const dto = new ProfileResponseDto(profile, company, review);
+ await this.populateLicenseFiles(company.id, dto.companyProfiles);
+ return dto;
+ }
+
+ @Get("profile/change-request")
+ @ApiOperation({
+ summary: "Current user's open profile change request (pending/rejected)",
+ })
+ async getMyChangeRequest(
+ @CurrentUser() user: CurrentIamUser,
+ ): Promise {
+ const { company } =
+ await this.companiesService.getCompanyInfoByUserId(user.id);
+ const review = await this.companiesService.getOpenChangeRequestForCompany(
+ company.id,
+ );
+ return review ? new ChangeRequestResponseDto(review) : null;
+ }
+
+ @Post("company-profiles/:profileId/reapply")
+ @ApiOperation({
+ summary: "Resubmit a rejected operational role for approval (→ pending)",
+ })
+ async reapplyCompanyProfile(
+ @CurrentUser() user: CurrentIamUser,
+ @Param("profileId", ParseUUIDPipe) profileId: string,
+ ): Promise {
+ const profile = await this.companiesService.reapplyCompanyProfile(
+ user.id,
+ profileId,
+ );
+ return new ResponseCompanyProfileDto(profile);
}
@Get("dashboard")
@@ -177,28 +237,72 @@ export class CompaniesController {
@ApiConsumes("multipart/form-data")
@ApiOperation({
summary:
- "Upload business-license document(s) for one of the current user's company profiles",
+ "Add business-license document(s) to a profile. For an approved company " +
+ "the upload is staged for backoffice review; during onboarding it goes live.",
})
async uploadProfileLicense(
@CurrentUser() user: CurrentIamUser,
@Param("profileId", ParseUUIDPipe) profileId: string,
@UploadedFiles() files: Array,
- ): Promise {
- return this.companiesService.uploadProfileLicenseFiles(
+ ): Promise {
+ return this.companiesService.addProfileLicenseFiles(
user.id,
profileId,
files,
);
}
+ @Post("company-profiles/:profileId/license/:fileId/replace")
+ @UseInterceptors(AnyFilesInterceptor())
+ @ApiConsumes("multipart/form-data")
+ @ApiOperation({
+ summary:
+ "Replace a business-license file with a newly uploaded one (staged for " +
+ "review on an approved company).",
+ })
+ async replaceProfileLicense(
+ @CurrentUser() user: CurrentIamUser,
+ @Param("profileId", ParseUUIDPipe) profileId: string,
+ @Param("fileId", ParseUUIDPipe) fileId: string,
+ @UploadedFiles() files: Array,
+ ): Promise {
+ const file = files?.[0];
+ if (!file) {
+ throw new BadRequestException("A replacement file is required");
+ }
+ return this.companiesService.replaceProfileLicenseFile(
+ user.id,
+ profileId,
+ fileId,
+ file,
+ );
+ }
+
+ @Delete("company-profiles/:profileId/license/:fileId")
+ @ApiOperation({
+ summary:
+ "Remove a business-license file (staged for review on an approved company).",
+ })
+ async removeProfileLicense(
+ @CurrentUser() user: CurrentIamUser,
+ @Param("profileId", ParseUUIDPipe) profileId: string,
+ @Param("fileId", ParseUUIDPipe) fileId: string,
+ ): Promise {
+ return this.companiesService.removeProfileLicenseFile(
+ user.id,
+ profileId,
+ fileId,
+ );
+ }
+
@Get("company-profiles/:profileId/license")
@ApiOperation({
- summary: "List business-license documents for a company profile",
+ summary: "List business-license documents (with review state) for a profile",
})
async listProfileLicense(
@CurrentUser() user: CurrentIamUser,
@Param("profileId", ParseUUIDPipe) profileId: string,
- ): Promise {
+ ): Promise {
return this.companiesService.listProfileLicenseFiles(user.id, profileId);
}
@@ -306,7 +410,9 @@ export class CompaniesController {
@Param("id", ParseUUIDPipe) id: string,
): Promise {
const company = await this.companiesService.findCompanyById(id);
- return new ResponseCompanyDto(company);
+ const dto = new ResponseCompanyDto(company);
+ await this.populateLicenseFiles(company.id, dto.companyProfiles ?? []);
+ return dto;
}
@Patch(":id")
@@ -354,26 +460,76 @@ export class CompaniesController {
@ApiConsumes("multipart/form-data")
@ApiOperation({ summary: "Upload documents for a company (onboarding)" })
async uploadDocuments(
+ @CurrentUser() user: CurrentIamUser,
@Param("companyId", ParseUUIDPipe) companyId: string,
@UploadedFiles() files: Array,
) {
- return this.filesService.uploadMany(companyId, "companies", files);
+ // Routed through the service so an approved company's uploads are staged for
+ // review (and lock the customer), while onboarding uploads pass straight through.
+ return this.companiesService.uploadCompanyDocuments(companyId, files, user.id);
}
@Patch("company-profiles/:profileId/status")
@FreightAdmin()
@ApiOperation({ summary: "Update a company profile's approval status" })
async updateCompanyProfileStatus(
+ @CurrentUser() user: CurrentIamUser,
@Param("profileId", ParseUUIDPipe) profileId: string,
@Body() dto: UpdateCompanyProfileStatusDto,
): Promise {
const profile = await this.companiesService.setCompanyProfileStatus(
profileId,
dto.status,
+ dto.note,
+ user.id,
);
return new ResponseCompanyProfileDto(profile);
}
+ @Get(":companyId/change-requests")
+ @FreightAdmin()
+ @ApiOperation({ summary: "List a company's profile change requests" })
+ async listChangeRequests(
+ @Param("companyId", ParseUUIDPipe) companyId: string,
+ ): Promise {
+ const requests = await this.companiesService.listChangeRequests(companyId);
+ return requests.map((r) => new ChangeRequestResponseDto(r));
+ }
+
+ @Post("change-requests/:id/approve")
+ @FreightAdmin()
+ @ApiOperation({
+ summary: "Approve a pending profile change request (applies the changes)",
+ })
+ async approveChangeRequest(
+ @CurrentUser() user: CurrentIamUser,
+ @Param("id", ParseUUIDPipe) id: string,
+ ): Promise {
+ const request = await this.companiesService.approveChangeRequest(
+ id,
+ user.id,
+ );
+ return new ChangeRequestResponseDto(request);
+ }
+
+ @Post("change-requests/:id/reject")
+ @FreightAdmin()
+ @ApiOperation({
+ summary: "Reject a pending profile change request with a note",
+ })
+ async rejectChangeRequest(
+ @CurrentUser() user: CurrentIamUser,
+ @Param("id", ParseUUIDPipe) id: string,
+ @Body() dto: RejectChangeRequestDto,
+ ): Promise {
+ const request = await this.companiesService.rejectChangeRequest(
+ id,
+ dto.note,
+ user.id,
+ );
+ return new ChangeRequestResponseDto(request);
+ }
+
@Post(":companyId/profiles")
@FreightAdmin()
@ApiOperation({ summary: "Add a profile (employee) to a company" })
diff --git a/apps/edr-freight-api/src/modules/companies/companies.module.ts b/apps/edr-freight-api/src/modules/companies/companies.module.ts
index 42186dd8e..646d01d47 100644
--- a/apps/edr-freight-api/src/modules/companies/companies.module.ts
+++ b/apps/edr-freight-api/src/modules/companies/companies.module.ts
@@ -12,13 +12,21 @@ import { CompanyDashboardRepository } from "./company-dashboard.repository";
import { Company } from "./entities/company.entity";
import { ExternalProfile } from "./entities/external-profile.entity";
import { CompanyProfile } from "./entities/company-profile.entity";
+import { CompanyChangeRequest } from "./entities/company-change-request.entity";
import { Booking } from "../bookings/entities/booking.entity";
import { CompanyProfileRepository } from "./company-profile.repository";
+import { CompanyChangeRequestRepository } from "./company-change-request.repository";
import { ETradeService } from "./services/etrade.service";
@Module({
imports: [
- TypeOrmModule.forFeature([Company, ExternalProfile, CompanyProfile, Booking]),
+ TypeOrmModule.forFeature([
+ Company,
+ ExternalProfile,
+ CompanyProfile,
+ CompanyChangeRequest,
+ Booking,
+ ]),
HttpModule,
FilesModule,
FileUploadSettingsModule,
@@ -30,6 +38,7 @@ import { ETradeService } from "./services/etrade.service";
CompaniesRepository,
ExternalProfileRepository,
CompanyProfileRepository,
+ CompanyChangeRequestRepository,
CompanyDashboardRepository,
ETradeService,
],
diff --git a/apps/edr-freight-api/src/modules/companies/companies.service.ts b/apps/edr-freight-api/src/modules/companies/companies.service.ts
index fe679627b..e31851aef 100644
--- a/apps/edr-freight-api/src/modules/companies/companies.service.ts
+++ b/apps/edr-freight-api/src/modules/companies/companies.service.ts
@@ -7,13 +7,14 @@ import {
} from "@nestjs/common";
import { CompaniesRepository } from "./companies.repository";
import { CompanyProfileRepository } from "./company-profile.repository";
+import { CompanyChangeRequestRepository } from "./company-change-request.repository";
import { ExternalProfileRepository } from "./external-profile.repository";
import {
CompanyDashboardRepository,
DashboardScope,
} from "./company-dashboard.repository";
-import { MinioService } from "../minio/minio.service";
import { FilesService } from "../files/files.service";
+import { FileRecord } from "../files/entities/file.entity";
import { FileUploadSettingsService } from "../file-upload-settings/file-upload-settings.service";
import { ETradeService } from "./services/etrade.service";
import { OnboardingRequirementsResponseDto } from "./dto/onboarding-requirements-response.dto";
@@ -37,9 +38,21 @@ import { ExternalProfile } from "./entities/external-profile.entity";
import {
BusinessLicenseFile,
CompanyProfile,
+ ProfileLicenseFileView,
ProfileType,
ProfileStatus,
} from "./entities/company-profile.entity";
+import {
+ ChangeRequestStatus,
+ CompanyChangeRequest,
+ LicenseChangeIntent,
+} from "./entities/company-change-request.entity";
+
+/** FileRecord `resource` + `code` slots for business-license documents. */
+const LICENSE_RESOURCE = "company_profiles";
+const LICENSE_CODE = "business_license";
+/** Code for a license file staged in an open change request (not yet live). */
+const LICENSE_PENDING_CODE = "business_license_pending";
export interface UserIdentity {
userId: string;
@@ -54,9 +67,9 @@ export class CompaniesService {
constructor(
private readonly companiesRepo: CompaniesRepository,
private readonly companyProfilesRepo: CompanyProfileRepository,
+ private readonly changeRequestRepo: CompanyChangeRequestRepository,
private readonly profilesRepo: ExternalProfileRepository,
private readonly dashboardRepo: CompanyDashboardRepository,
- private readonly minioService: MinioService,
private readonly filesService: FilesService,
private readonly fileUploadSettingsService: FileUploadSettingsService,
private readonly etradeService: ETradeService,
@@ -334,28 +347,9 @@ export class CompaniesService {
const company = await this.companiesRepo.findById(id);
if (!company) throw new NotFoundException(`Company ${id} not found`);
company.companyProfiles = await this.companyProfilesRepo.findByCompanyId(id);
- for (const profile of company.companyProfiles) {
- profile.businessLicenseFiles = await this.signLicenseFiles(
- profile.businessLicenseFiles,
- );
- }
return company;
}
- /**
- * Business-license files are stored as raw, unsigned MinIO URLs (see
- * `BusinessLicenseFile` on `CompanyProfile`) — a browser can't fetch them
- * directly. Sign each one with a short-lived URL before it reaches a response.
- */
- private async signLicenseFiles(
- files?: BusinessLicenseFile[] | null,
- ): Promise {
- if (!files?.length) return [];
- return Promise.all(
- files.map(async (f) => ({ ...f, url: await this.filesService.signUrl(f.url) })),
- );
- }
-
/**
* Validate an explicitly-chosen company profile for a booking: it must belong
* to the booking's company and be Active. Used for government bookings (staff
@@ -574,12 +568,24 @@ export class CompaniesService {
return updated;
}
- async updateProfile(
- userId: string,
- dto: UpdateProfileDto,
- ): Promise {
- const { profile, company } = await this.getCompanyInfoByUserId(userId);
+ /** Keep only the keys that were actually provided (drop `undefined`). */
+ private pickDefined(dto: Record): Record {
+ const out: Record = {};
+ for (const [k, v] of Object.entries(dto)) {
+ if (v !== undefined) out[k] = v;
+ }
+ return out;
+ }
+ /**
+ * Translate an UpdateProfileDto (or a staged change-request snapshot) into a
+ * `Company` patch: scalar columns plus a merged `attributes` blob (contact/GM/
+ * PoA live there). Pure — the caller runs the async TIN-uniqueness check.
+ */
+ private mapProfileDtoToCompanyUpdates(
+ company: Company,
+ dto: Partial,
+ ): Record {
const companyUpdates: Record = {};
const attrUpdates: Record = { ...(company.attributes ?? {}) };
@@ -593,21 +599,10 @@ export class CompaniesService {
companyUpdates.country = dto.companyLocation;
if (dto.companyAddress !== undefined)
companyUpdates.address = dto.companyAddress;
- if (dto.tin !== undefined && dto.tin !== company.tin) {
- // Reject a TIN already taken by a different company (the user's own draft
- // placeholder is fine to overwrite).
- const owner = await this.companiesRepo.findByTin(dto.tin);
- if (owner && owner.id !== company.id) {
- throw new ConflictException(
- `This TIN (${dto.tin}) is already registered to another company. Please check the number and try again.`,
- );
- }
+ if (dto.tin !== undefined && dto.tin !== company.tin)
companyUpdates.tin = dto.tin;
- }
if (dto.vatNumber !== undefined) companyUpdates.vatNumber = dto.vatNumber;
- if (dto.fanNumber !== undefined) {
- companyUpdates.fanNumber = dto.fanNumber;
- }
+ if (dto.fanNumber !== undefined) companyUpdates.fanNumber = dto.fanNumber;
if (dto.contactPersonName !== undefined)
attrUpdates.contactPersonName = dto.contactPersonName;
@@ -629,8 +624,7 @@ export class CompaniesService {
if (dto.poaPhone !== undefined)
attrUpdates.poaPhone = normalizeE164(dto.poaPhone);
if (dto.poaEmail !== undefined) attrUpdates.poaEmail = dto.poaEmail;
- if (dto.poaLocation !== undefined)
- attrUpdates.poaLocation = dto.poaLocation;
+ if (dto.poaLocation !== undefined) attrUpdates.poaLocation = dto.poaLocation;
if (dto.poaAddress !== undefined) attrUpdates.poaAddress = dto.poaAddress;
if (dto.licenceNumber !== undefined)
@@ -653,11 +647,219 @@ export class CompaniesService {
companyUpdates.etradePhone = normalizeE164(dto.etradePhone);
companyUpdates.attributes = attrUpdates;
+ return companyUpdates;
+ }
- const updated = await this.companiesRepo.update(company.id, companyUpdates);
- if (!updated)
- throw new NotFoundException(`Company ${company.id} not found`);
- return new ProfileResponseDto(profile, updated);
+ /** Reject a TIN already registered to a *different* company. */
+ private async assertTinAvailable(
+ company: Company,
+ tin: string | undefined,
+ ): Promise {
+ if (tin === undefined || tin === company.tin) return;
+ const owner = await this.companiesRepo.findByTin(tin);
+ if (owner && owner.id !== company.id) {
+ throw new ConflictException(
+ `This TIN (${tin}) is already registered to another company. Please check the number and try again.`,
+ );
+ }
+ }
+
+ /** The company's open (pending or last-rejected) profile change request. */
+ async getOpenChangeRequestForCompany(
+ companyId: string,
+ ): Promise {
+ return this.changeRequestRepo.findLatestOpenByCompanyId(companyId);
+ }
+
+ /**
+ * Update the current user's profile.
+ *
+ * - Company not yet approved (onboarding) → write straight to the Company row,
+ * as before. The company/role pending→approve gate already covers first-run.
+ * - Company already `active` → do NOT touch the live Company. Stage the edit in
+ * a pending change request (merging into any open one) so a backoffice
+ * reviewer can approve (apply) or reject (with a note). This locks the
+ * customer until the review resolves.
+ */
+ async updateProfile(
+ userId: string,
+ dto: UpdateProfileDto,
+ ): Promise {
+ const { profile, company } = await this.getCompanyInfoByUserId(userId);
+
+ if (company.status !== CompanyStatus.Active) {
+ await this.assertTinAvailable(company, dto.tin);
+ const companyUpdates = this.mapProfileDtoToCompanyUpdates(company, dto);
+ const updated = await this.companiesRepo.update(
+ company.id,
+ companyUpdates,
+ );
+ if (!updated)
+ throw new NotFoundException(`Company ${company.id} not found`);
+ return new ProfileResponseDto(profile, updated);
+ }
+
+ // Approved company: stage the change for review, leaving the live row intact.
+ await this.assertTinAvailable(company, dto.tin);
+ const fields = this.pickDefined(dto);
+
+ const existing = await this.changeRequestRepo.findPendingByCompanyId(
+ company.id,
+ );
+ const now = new Date();
+ let request: CompanyChangeRequest;
+ if (existing) {
+ request =
+ (await this.changeRequestRepo.update(existing.id, {
+ snapshot: { ...(existing.snapshot ?? {}), ...fields },
+ submittedBy: userId,
+ submittedAt: now,
+ note: null,
+ })) ?? existing;
+ } else {
+ request = await this.changeRequestRepo.create({
+ companyId: company.id,
+ snapshot: fields,
+ status: ChangeRequestStatus.Pending,
+ submittedBy: userId,
+ submittedAt: now,
+ });
+ }
+
+ // Live company is unchanged; surface the pending state for the settings page.
+ return new ProfileResponseDto(profile, company, request);
+ }
+
+ /** List a company's change requests, newest first (backoffice review). */
+ async listChangeRequests(
+ companyId: string,
+ ): Promise {
+ await this.findCompanyById(companyId);
+ return this.changeRequestRepo.findByCompanyId(companyId);
+ }
+
+ /**
+ * Approve a pending change request: apply its snapshot to the live Company and
+ * mark the request approved. Any staged documents are already attached to the
+ * company, so nothing else needs promoting.
+ */
+ async approveChangeRequest(
+ id: string,
+ reviewerId?: string,
+ ): Promise {
+ const request = await this.changeRequestRepo.findById(id);
+ if (!request)
+ throw new NotFoundException(`Change request ${id} not found`);
+ if (request.status !== ChangeRequestStatus.Pending) {
+ throw new BadRequestException(
+ `Change request ${id} is already ${request.status}`,
+ );
+ }
+
+ const company = await this.companiesRepo.findById(request.companyId);
+ if (!company)
+ throw new NotFoundException(`Company ${request.companyId} not found`);
+
+ const snapshot = (request.snapshot ?? {}) as Partial;
+ await this.assertTinAvailable(company, snapshot.tin);
+ const companyUpdates = this.mapProfileDtoToCompanyUpdates(company, snapshot);
+ await this.companiesRepo.update(company.id, companyUpdates);
+ await this.applyLicenseChanges(request);
+
+ return (
+ (await this.changeRequestRepo.update(id, {
+ status: ChangeRequestStatus.Approved,
+ reviewedBy: reviewerId ?? null,
+ reviewedAt: new Date(),
+ note: null,
+ })) ?? request
+ );
+ }
+
+ /**
+ * Upload company documents. For an approved company this also opens/updates a
+ * pending change request (recording the uploaded file ids) so the upload is
+ * reviewed and the customer is locked until it clears — consistent with the
+ * field-edit review. During onboarding (company not yet active) it's a plain
+ * upload with no review.
+ */
+ async uploadCompanyDocuments(
+ companyId: string,
+ files: Express.Multer.File[],
+ submittedBy?: string,
+ ): Promise {
+ const company = await this.findCompanyById(companyId);
+ const uploaded = await this.filesService.uploadMany(
+ companyId,
+ "companies",
+ files,
+ );
+ if (company.status === CompanyStatus.Active) {
+ await this.stageDocumentChange(
+ company.id,
+ uploaded.map((f) => f.id),
+ submittedBy,
+ );
+ }
+ return uploaded;
+ }
+
+ /** Open or append a pending change request recording staged document uploads. */
+ private async stageDocumentChange(
+ companyId: string,
+ fileIds: string[],
+ submittedBy?: string,
+ ): Promise {
+ if (fileIds.length === 0) return;
+ const now = new Date();
+ const existing =
+ await this.changeRequestRepo.findPendingByCompanyId(companyId);
+ if (existing) {
+ const prev = existing.documents?.documentFileIds ?? [];
+ await this.changeRequestRepo.update(existing.id, {
+ documents: { documentFileIds: [...prev, ...fileIds] },
+ submittedBy: submittedBy ?? existing.submittedBy ?? null,
+ submittedAt: now,
+ note: null,
+ });
+ } else {
+ await this.changeRequestRepo.create({
+ companyId,
+ snapshot: {},
+ documents: { documentFileIds: fileIds },
+ status: ChangeRequestStatus.Pending,
+ submittedBy: submittedBy ?? null,
+ submittedAt: now,
+ });
+ }
+ }
+
+ /** Reject a pending change request with a note (customer amends & resubmits). */
+ async rejectChangeRequest(
+ id: string,
+ note: string,
+ reviewerId?: string,
+ ): Promise {
+ const request = await this.changeRequestRepo.findById(id);
+ if (!request)
+ throw new NotFoundException(`Change request ${id} not found`);
+ if (request.status !== ChangeRequestStatus.Pending) {
+ throw new BadRequestException(
+ `Change request ${id} is already ${request.status}`,
+ );
+ }
+ await this.discardLicenseChanges(request);
+ return (
+ (await this.changeRequestRepo.update(id, {
+ status: ChangeRequestStatus.Rejected,
+ // Staged license uploads were just discarded; drop their intents so an
+ // amended resubmit never re-references deleted files.
+ documents: { ...request.documents, licenseChanges: [] },
+ note,
+ reviewedBy: reviewerId ?? null,
+ reviewedAt: new Date(),
+ })) ?? request
+ );
}
async deleteCompany(id: string): Promise {
@@ -713,6 +915,8 @@ export class CompaniesService {
async setCompanyProfileStatus(
profileId: string,
status: ProfileStatus,
+ note?: string,
+ reviewerId?: string,
): Promise {
const existing = await this.companyProfilesRepo.findById(profileId);
if (!existing)
@@ -727,6 +931,18 @@ export class CompaniesService {
);
}
+ // Track the review outcome. Rejection keeps the note so the customer knows
+ // why; approval clears it. Any decision stamps the reviewer + time.
+ if (status === ProfileStatus.Rejected) {
+ patch.reviewNote = note ?? null;
+ } else if (status === ProfileStatus.Active) {
+ patch.reviewNote = null;
+ }
+ if (status !== ProfileStatus.Pending) {
+ patch.reviewedBy = reviewerId ?? null;
+ patch.reviewedAt = new Date();
+ }
+
const updated = await this.companyProfilesRepo.update(profileId, patch);
if (!updated)
throw new NotFoundException(`Company profile ${profileId} not found`);
@@ -744,6 +960,41 @@ export class CompaniesService {
return updated;
}
+ /**
+ * Customer reapplies for a rejected operational role (after fixing whatever the
+ * reviewer flagged, e.g. re-uploading a license): flip it back to Pending and
+ * clear the rejection note so it re-enters the approval queue.
+ */
+ async reapplyCompanyProfile(
+ userId: string,
+ profileId: string,
+ ): Promise {
+ const profile = await this.profilesRepo.findByUserId(userId);
+ if (!profile)
+ throw new NotFoundException(`Profile for user ${userId} not found`);
+ const companyId = profile.company?.id ?? profile.companyId;
+
+ const target = await this.companyProfilesRepo.findById(profileId);
+ if (!target || target.companyId !== companyId) {
+ throw new NotFoundException(`Company profile ${profileId} not found`);
+ }
+ if (target.status !== ProfileStatus.Rejected) {
+ throw new BadRequestException(
+ "Only a rejected role can be resubmitted for approval",
+ );
+ }
+
+ const updated = await this.companyProfilesRepo.update(profileId, {
+ status: ProfileStatus.Pending,
+ reviewNote: null,
+ reviewedBy: null,
+ reviewedAt: null,
+ });
+ if (!updated)
+ throw new NotFoundException(`Company profile ${profileId} not found`);
+ return updated;
+ }
+
async createCompanyProfile(
companyId: string,
profileType?: ProfileType,
@@ -833,12 +1084,12 @@ export class CompaniesService {
);
if (existing) continue;
- const reference = await this.companyProfilesRepo.generateReference(type);
+ // Self-service role adds start Pending and carry no reference — a reference
+ // is minted only when a backoffice reviewer approves the role.
await this.companyProfilesRepo.create({
companyId,
type,
- reference,
- status: ProfileStatus.Active,
+ status: ProfileStatus.Pending,
});
}
@@ -870,13 +1121,14 @@ export class CompaniesService {
let created = await this.companyProfilesRepo.findByType(companyId, type);
if (!created) {
- const reference = await this.companyProfilesRepo.generateReference(type);
+ // New self-service roles start Pending (awaiting backoffice approval) and
+ // carry no reference until approved. The customer can select this mode but
+ // can't book under it until it's cleared.
created = await this.companyProfilesRepo.create({
companyId,
type,
- reference,
businessLicense: businessLicense ?? null,
- status: ProfileStatus.Active,
+ status: ProfileStatus.Pending,
});
}
@@ -971,13 +1223,21 @@ export class CompaniesService {
}));
const missingDocs = documents.filter((d) => d.isRequired && !d.uploaded);
- // 3. Per-operational-profile business licenses.
- const licenseProfiles = (company.companyProfiles ?? []).map((p) => ({
- profileId: p.id,
- type: p.type,
- reference: p.reference ?? "",
- uploaded: (p.businessLicenseFiles?.length ?? 0) > 0,
- }));
+ // 3. Per-operational-profile business licenses (FileRecord-backed).
+ const licenseProfiles = await Promise.all(
+ (company.companyProfiles ?? []).map(async (p) => {
+ const records = await this.filesService.findByResource(
+ p.id,
+ LICENSE_RESOURCE,
+ );
+ return {
+ profileId: p.id,
+ type: p.type,
+ reference: p.reference ?? "",
+ uploaded: records.some((r) => r.code === LICENSE_CODE),
+ };
+ }),
+ );
const missingLicenses = licenseProfiles.filter((p) => !p.uploaded);
const outstanding = [
@@ -1094,61 +1354,321 @@ export class CompaniesService {
return owned;
}
+ // ─── Business-license files ────────────────────────────────────────────────
+ //
+ // License documents live in the FileRecord model (`freight.files`) with
+ // `resource = "company_profiles"`, `resourceId = `. Live files use
+ // code `LICENSE_CODE`; files staged inside an open change request (add /
+ // replacement) use `LICENSE_PENDING_CODE` and only become live on approval.
+ // Preview streams through `GET /api/files/:id` (server-side proxy) — the same
+ // path regular documents use — so it never hits MinIO directly from the
+ // browser (which fails on the internal bucket endpoint).
+
/**
- * Upload business-license document(s) and store them directly on the company
- * profile (multi-file). Bytes go to object storage; only metadata/URLs are
- * persisted on the profile — intentionally not via the FileRecord file model.
- * New files are appended to any already present. Returns the full list.
+ * Upload business-license file(s) for one of the user's profiles. During
+ * onboarding (company not yet Active) they go live immediately; for an Active
+ * company they're staged under the pending code and recorded as `add` intents
+ * on a pending change request for backoffice review. Returns the updated view.
*/
- async uploadProfileLicenseFiles(
+ async addProfileLicenseFiles(
userId: string,
profileId: string,
files: Express.Multer.File[],
- ): Promise {
+ ): Promise {
const profile = await this.resolveOwnedProfile(userId, profileId);
+ const company = await this.findCompanyById(profile.companyId);
+ const gated = company.status === CompanyStatus.Active;
+ const code = gated ? LICENSE_PENDING_CODE : LICENSE_CODE;
- const uploaded: BusinessLicenseFile[] = [];
- for (const file of files) {
- const objectName = `company_profiles/${profileId}/${Date.now()}_${file.originalname}`;
- const url = await this.minioService.uploadFile(
- objectName,
- file.buffer,
- file.mimetype,
+ const uploaded = await Promise.all(
+ files.map((file) =>
+ this.filesService.upload({
+ resourceId: profileId,
+ resource: LICENSE_RESOURCE,
+ code,
+ file,
+ }),
+ ),
+ );
+
+ if (gated) {
+ await this.stageLicenseChange(
+ company.id,
+ uploaded.map((r) => ({
+ profileId,
+ op: "add" as const,
+ fileId: r.id,
+ fileName: r.name,
+ })),
+ userId,
);
- uploaded.push({
- name: file.originalname,
- url,
- size: file.size,
- mimeType: file.mimetype,
- });
}
- const next = [...(profile.businessLicenseFiles ?? []), ...uploaded];
- await this.companyProfilesRepo.update(profileId, {
- businessLicenseFiles: next,
- });
- return next;
- }
-
- /** The business-license files stored on a single company profile. */
- async listProfileLicenseFiles(
- userId: string,
- profileId: string,
- ): Promise {
- const profile = await this.resolveOwnedProfile(userId, profileId);
- return profile.businessLicenseFiles ?? [];
+ return this.getProfileLicenseView(profileId, company.id);
}
/**
- * Onboarding documents stored on a company profile, fetched by profile id.
- * Internal helper (no ownership check) used when a booking reuses the active
- * profile's onboarding documents. Returns [] when the profile is unknown.
+ * Remove a license file. A staged (pending) file is withdrawn outright
+ * (soft-deleted, its `add` intent dropped). A live file on an Active company
+ * is kept and recorded as a `remove` intent for review; during onboarding it
+ * is deleted immediately.
+ */
+ async removeProfileLicenseFile(
+ userId: string,
+ profileId: string,
+ fileId: string,
+ ): Promise {
+ const profile = await this.resolveOwnedProfile(userId, profileId);
+ const record = await this.filesService.findById(fileId);
+ if (
+ record.resource !== LICENSE_RESOURCE ||
+ record.resourceId !== profileId
+ ) {
+ throw new NotFoundException(`License file ${fileId} not found`);
+ }
+ const company = await this.findCompanyById(profile.companyId);
+ const gated = company.status === CompanyStatus.Active;
+
+ if (record.code === LICENSE_PENDING_CODE) {
+ // Withdraw a not-yet-approved upload: delete it and drop its add intent.
+ await this.filesService.remove(fileId);
+ await this.withdrawLicenseIntent(company.id, fileId);
+ } else if (gated) {
+ await this.stageLicenseChange(
+ company.id,
+ [{ profileId, op: "remove", fileId, fileName: record.name }],
+ userId,
+ );
+ } else {
+ await this.filesService.remove(fileId);
+ }
+
+ return this.getProfileLicenseView(profileId, company.id);
+ }
+
+ /**
+ * Replace a live license file with a freshly uploaded one — recorded as a
+ * `remove` of the old file plus an `add` of the new, so approval swaps them
+ * atomically. During onboarding the swap is applied immediately.
+ */
+ async replaceProfileLicenseFile(
+ userId: string,
+ profileId: string,
+ fileId: string,
+ file: Express.Multer.File,
+ ): Promise {
+ const profile = await this.resolveOwnedProfile(userId, profileId);
+ const old = await this.filesService.findById(fileId);
+ if (old.resource !== LICENSE_RESOURCE || old.resourceId !== profileId) {
+ throw new NotFoundException(`License file ${fileId} not found`);
+ }
+ const company = await this.findCompanyById(profile.companyId);
+ const gated = company.status === CompanyStatus.Active;
+
+ const created = await this.filesService.upload({
+ resourceId: profileId,
+ resource: LICENSE_RESOURCE,
+ code: gated ? LICENSE_PENDING_CODE : LICENSE_CODE,
+ file,
+ });
+
+ if (gated) {
+ await this.stageLicenseChange(
+ company.id,
+ [
+ { profileId, op: "remove", fileId, fileName: old.name },
+ { profileId, op: "add", fileId: created.id, fileName: created.name },
+ ],
+ userId,
+ );
+ } else {
+ await this.filesService.remove(fileId);
+ }
+
+ return this.getProfileLicenseView(profileId, company.id);
+ }
+
+ /** License files for one profile, with each file's review status resolved. */
+ async listProfileLicenseFiles(
+ userId: string,
+ profileId: string,
+ ): Promise {
+ const profile = await this.resolveOwnedProfile(userId, profileId);
+ return this.getProfileLicenseView(profileId, profile.companyId);
+ }
+
+ /**
+ * Live license files for a profile, shaped for by-reference reuse (bookings /
+ * contracts snapshot these). No ownership check — internal callers only.
+ * Returns the raw stored URLs; pending (unapproved) files are excluded.
*/
async getProfileOnboardingFiles(
profileId: string,
): Promise {
- const profile = await this.companyProfilesRepo.findById(profileId);
- return profile?.businessLicenseFiles ?? [];
+ const records = await this.filesService.findByResource(
+ profileId,
+ LICENSE_RESOURCE,
+ );
+ return records
+ .filter((r) => r.code === LICENSE_CODE)
+ .map((r) => ({
+ name: r.name,
+ url: r.url,
+ size: r.size,
+ mimeType: r.mimeType,
+ }));
+ }
+
+ /**
+ * Assemble the review-aware license view for a set of profiles in one pass
+ * (single change-request lookup). Used to enrich company/profile responses.
+ */
+ async assembleLicenseFilesByProfile(
+ companyId: string,
+ profileIds: string[],
+ ): Promise> {
+ const pending =
+ await this.changeRequestRepo.findPendingByCompanyId(companyId);
+ const removeIds = new Set(
+ (pending?.documents?.licenseChanges ?? [])
+ .filter((c) => c.op === "remove")
+ .map((c) => c.fileId),
+ );
+ const result: Record = {};
+ await Promise.all(
+ profileIds.map(async (pid) => {
+ result[pid] = await this.mapLicenseRecords(pid, removeIds);
+ }),
+ );
+ return result;
+ }
+
+ /** Single-profile license view (fetches the company's pending request once). */
+ private async getProfileLicenseView(
+ profileId: string,
+ companyId: string,
+ ): Promise {
+ const pending =
+ await this.changeRequestRepo.findPendingByCompanyId(companyId);
+ const removeIds = new Set(
+ (pending?.documents?.licenseChanges ?? [])
+ .filter((c) => c.op === "remove")
+ .map((c) => c.fileId),
+ );
+ return this.mapLicenseRecords(profileId, removeIds);
+ }
+
+ private async mapLicenseRecords(
+ profileId: string,
+ pendingRemoveIds: Set,
+ ): Promise {
+ const records = await this.filesService.findByResource(
+ profileId,
+ LICENSE_RESOURCE,
+ );
+ return records
+ .filter(
+ (r) => r.code === LICENSE_CODE || r.code === LICENSE_PENDING_CODE,
+ )
+ .map((r) => ({
+ id: r.id,
+ name: r.name,
+ size: r.size,
+ mimeType: r.mimeType,
+ status:
+ r.code === LICENSE_PENDING_CODE
+ ? ("pending_add" as const)
+ : pendingRemoveIds.has(r.id)
+ ? ("pending_remove" as const)
+ : ("live" as const),
+ }));
+ }
+
+ /** Open or append a pending change request recording license add/remove intents. */
+ private async stageLicenseChange(
+ companyId: string,
+ changes: LicenseChangeIntent[],
+ submittedBy?: string,
+ ): Promise {
+ if (changes.length === 0) return;
+ const now = new Date();
+ const existing =
+ await this.changeRequestRepo.findPendingByCompanyId(companyId);
+ if (existing) {
+ const prev = existing.documents?.licenseChanges ?? [];
+ await this.changeRequestRepo.update(existing.id, {
+ documents: {
+ ...existing.documents,
+ licenseChanges: [...prev, ...changes],
+ },
+ submittedBy: submittedBy ?? existing.submittedBy ?? null,
+ submittedAt: now,
+ note: null,
+ });
+ } else {
+ await this.changeRequestRepo.create({
+ companyId,
+ snapshot: {},
+ documents: { licenseChanges: changes },
+ status: ChangeRequestStatus.Pending,
+ submittedBy: submittedBy ?? null,
+ submittedAt: now,
+ });
+ }
+ }
+
+ /**
+ * Drop a staged license intent (add or remove) referencing `fileId` from the
+ * company's open request. If that empties the request entirely, delete it so
+ * the customer's settings page unlocks.
+ */
+ private async withdrawLicenseIntent(
+ companyId: string,
+ fileId: string,
+ ): Promise {
+ const existing =
+ await this.changeRequestRepo.findPendingByCompanyId(companyId);
+ if (!existing) return;
+ const remaining = (existing.documents?.licenseChanges ?? []).filter(
+ (c) => c.fileId !== fileId,
+ );
+ const docs = existing.documents ?? {};
+ const stillHasWork =
+ remaining.length > 0 ||
+ (docs.documentFileIds?.length ?? 0) > 0 ||
+ Object.keys(existing.snapshot ?? {}).length > 0;
+
+ if (stillHasWork) {
+ await this.changeRequestRepo.update(existing.id, {
+ documents: { ...docs, licenseChanges: remaining },
+ });
+ } else {
+ await this.changeRequestRepo.softDelete(existing.id);
+ }
+ }
+
+ /** Apply a request's staged license changes: promote adds, delete removes. */
+ private async applyLicenseChanges(
+ request: CompanyChangeRequest,
+ ): Promise {
+ for (const change of request.documents?.licenseChanges ?? []) {
+ if (change.op === "add") {
+ await this.filesService.setCode(change.fileId, LICENSE_CODE);
+ } else {
+ await this.filesService.remove(change.fileId);
+ }
+ }
+ }
+
+ /** Discard a rejected request's staged license uploads (adds only). */
+ private async discardLicenseChanges(
+ request: CompanyChangeRequest,
+ ): Promise {
+ for (const change of request.documents?.licenseChanges ?? []) {
+ if (change.op === "add") {
+ await this.filesService.remove(change.fileId);
+ }
+ }
}
/**
diff --git a/apps/edr-freight-api/src/modules/companies/company-change-request.repository.ts b/apps/edr-freight-api/src/modules/companies/company-change-request.repository.ts
new file mode 100644
index 000000000..24d988452
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/companies/company-change-request.repository.ts
@@ -0,0 +1,55 @@
+import { Injectable } from "@nestjs/common";
+import { InjectRepository } from "@nestjs/typeorm";
+import { Repository } from "typeorm";
+import { BaseRepository } from "@edr/api-common";
+import {
+ ChangeRequestStatus,
+ CompanyChangeRequest,
+} from "./entities/company-change-request.entity";
+
+@Injectable()
+export class CompanyChangeRequestRepository extends BaseRepository {
+ constructor(
+ @InjectRepository(CompanyChangeRequest)
+ repo: Repository,
+ ) {
+ super(repo);
+ }
+
+ /** The company's current pending request, if any. */
+ async findPendingByCompanyId(
+ companyId: string,
+ ): Promise {
+ return this.repository.findOne({
+ where: { companyId, status: ChangeRequestStatus.Pending },
+ order: { createdAt: "DESC" },
+ });
+ }
+
+ /**
+ * The company's latest "open" request — pending (locks the customer) or the
+ * most recent rejected one (drives the reapply banner + prefill). Approved
+ * requests are terminal and ignored here.
+ */
+ async findLatestOpenByCompanyId(
+ companyId: string,
+ ): Promise {
+ const pending = await this.findPendingByCompanyId(companyId);
+ if (pending) return pending;
+ return this.repository.findOne({
+ where: { companyId, status: ChangeRequestStatus.Rejected },
+ order: { createdAt: "DESC" },
+ });
+ }
+
+ async findById(id: string): Promise {
+ return this.repository.findOne({ where: { id } });
+ }
+
+ async findByCompanyId(companyId: string): Promise {
+ return this.repository.find({
+ where: { companyId },
+ order: { createdAt: "DESC" },
+ });
+ }
+}
diff --git a/apps/edr-freight-api/src/modules/companies/dto/change-request-response.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/change-request-response.dto.ts
new file mode 100644
index 000000000..579ac6ddd
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/companies/dto/change-request-response.dto.ts
@@ -0,0 +1,44 @@
+import {
+ ChangeRequestStatus,
+ CompanyChangeRequest,
+ LicenseChangeIntent,
+} from "../entities/company-change-request.entity";
+
+/**
+ * A staged profile change request. Used both by the portal (to lock the settings
+ * page, show the reviewer note, and prefill the proposed values) and by the
+ * backoffice review screen (to render the proposed-vs-current diff).
+ */
+export class ChangeRequestResponseDto {
+ id: string;
+ companyId: string;
+ status: ChangeRequestStatus;
+ /** Proposed field values (Partial) — the diff payload. */
+ snapshot: Record;
+ documentFileIds: string[];
+ /** Staged business-license add/remove intents attached to this request. */
+ licenseChanges: LicenseChangeIntent[];
+ note: string | null;
+ submittedBy: string | null;
+ submittedAt: Date | null;
+ reviewedBy: string | null;
+ reviewedAt: Date | null;
+ createdAt: Date;
+ updatedAt: Date;
+
+ constructor(req: CompanyChangeRequest) {
+ this.id = req.id;
+ this.companyId = req.companyId;
+ this.status = req.status;
+ this.snapshot = req.snapshot ?? {};
+ this.documentFileIds = req.documents?.documentFileIds ?? [];
+ this.licenseChanges = req.documents?.licenseChanges ?? [];
+ this.note = req.note ?? null;
+ this.submittedBy = req.submittedBy ?? null;
+ this.submittedAt = req.submittedAt ?? null;
+ this.reviewedBy = req.reviewedBy ?? null;
+ this.reviewedAt = req.reviewedAt ?? null;
+ this.createdAt = req.createdAt;
+ this.updatedAt = req.updatedAt;
+ }
+}
diff --git a/apps/edr-freight-api/src/modules/companies/dto/company-info-response.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/company-info-response.dto.ts
index 04fd42816..9a4fb330a 100644
--- a/apps/edr-freight-api/src/modules/companies/dto/company-info-response.dto.ts
+++ b/apps/edr-freight-api/src/modules/companies/dto/company-info-response.dto.ts
@@ -1,14 +1,43 @@
import { Company } from '../entities/company.entity';
import { ExternalProfile } from '../entities/external-profile.entity';
+import {
+ ChangeRequestStatus,
+ CompanyChangeRequest,
+} from '../entities/company-change-request.entity';
import { ResponseCompanyDto } from './response-company.dto';
import { ResponseExternalProfileDto } from './response-external-profile.dto';
export class CompanyInfoResponseDto {
profile: ResponseExternalProfileDto;
company: ResponseCompanyDto;
+ /**
+ * Open profile-edit review, if any. Drives the portal-wide lock (pending →
+ * settings + new-contract/booking creation disabled) and the reapply banner.
+ */
+ review: {
+ status: 'pending' | 'rejected';
+ note: string | null;
+ } | null;
- constructor(profile: ExternalProfile, company: Company) {
+ constructor(
+ profile: ExternalProfile,
+ company: Company,
+ changeRequest?: CompanyChangeRequest | null,
+ ) {
this.profile = new ResponseExternalProfileDto(profile, company);
this.company = new ResponseCompanyDto(company);
+
+ const open =
+ changeRequest &&
+ (changeRequest.status === ChangeRequestStatus.Pending ||
+ changeRequest.status === ChangeRequestStatus.Rejected)
+ ? changeRequest
+ : null;
+ this.review = open
+ ? {
+ status: open.status as 'pending' | 'rejected',
+ note: open.note ?? null,
+ }
+ : null;
}
}
diff --git a/apps/edr-freight-api/src/modules/companies/dto/profile-response.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/profile-response.dto.ts
index 89a52b5e6..89ab954e7 100644
--- a/apps/edr-freight-api/src/modules/companies/dto/profile-response.dto.ts
+++ b/apps/edr-freight-api/src/modules/companies/dto/profile-response.dto.ts
@@ -1,5 +1,9 @@
import { Company } from '../entities/company.entity';
import { ExternalProfile } from '../entities/external-profile.entity';
+import {
+ ChangeRequestStatus,
+ CompanyChangeRequest,
+} from '../entities/company-change-request.entity';
import { ResponseCompanyProfileDto } from './response-company.dto';
export class ProfileResponseDto {
@@ -48,7 +52,20 @@ export class ProfileResponseDto {
profileId: string;
- constructor(profile: ExternalProfile, company: Company) {
+ /**
+ * Open profile-edit review, if any. `reviewStatus === "pending"` locks the
+ * settings page; `"rejected"` surfaces the note and prefills the (declined)
+ * proposed values from `pendingChanges` so the customer can amend & resubmit.
+ */
+ reviewStatus: "pending" | "rejected" | null;
+ reviewNote: string | null;
+ pendingChanges: Record | null;
+
+ constructor(
+ profile: ExternalProfile,
+ company: Company,
+ changeRequest?: CompanyChangeRequest | null,
+ ) {
this.companyId = company.id;
this.companyName = company.name;
this.companyType = company.type;
@@ -92,5 +109,20 @@ export class ProfileResponseDto {
this.poaEmail = attrs.poaEmail ?? null;
this.poaLocation = attrs.poaLocation ?? null;
this.poaAddress = attrs.poaAddress ?? null;
+
+ const openReview =
+ changeRequest &&
+ (changeRequest.status === ChangeRequestStatus.Pending ||
+ changeRequest.status === ChangeRequestStatus.Rejected)
+ ? changeRequest
+ : null;
+ this.reviewStatus =
+ openReview?.status === ChangeRequestStatus.Pending
+ ? "pending"
+ : openReview?.status === ChangeRequestStatus.Rejected
+ ? "rejected"
+ : null;
+ this.reviewNote = openReview?.note ?? null;
+ this.pendingChanges = openReview?.snapshot ?? null;
}
}
diff --git a/apps/edr-freight-api/src/modules/companies/dto/reject-change-request.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/reject-change-request.dto.ts
new file mode 100644
index 000000000..c32b44a79
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/companies/dto/reject-change-request.dto.ts
@@ -0,0 +1,11 @@
+import { ApiProperty } from "@nestjs/swagger";
+import { IsString, MaxLength, MinLength } from "class-validator";
+
+export class RejectChangeRequestDto {
+ /** Why the proposed changes were declined — shown to the customer so they can fix and resubmit. */
+ @ApiProperty()
+ @IsString()
+ @MinLength(1)
+ @MaxLength(2000)
+ note!: string;
+}
diff --git a/apps/edr-freight-api/src/modules/companies/dto/response-company.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/response-company.dto.ts
index 5d90d8d60..0c783cbcf 100644
--- a/apps/edr-freight-api/src/modules/companies/dto/response-company.dto.ts
+++ b/apps/edr-freight-api/src/modules/companies/dto/response-company.dto.ts
@@ -5,8 +5,8 @@ import {
CompanyNationality,
} from '../entities/company.entity';
import {
- BusinessLicenseFile,
CompanyProfile,
+ ProfileLicenseFileView,
} from '../entities/company-profile.entity';
import { ResponseExternalProfileDto } from './response-external-profile.dto';
@@ -18,9 +18,15 @@ export class ResponseCompanyProfileDto {
status: string;
/** @deprecated Superseded by licenseFiles. Kept for back-compat. */
businessLicense?: string | null;
- /** Business-license documents stored on the profile (multi-file). */
- licenseFiles: BusinessLicenseFile[];
+ /**
+ * Business-license documents (FileRecord-backed) with review state. Left empty
+ * by the constructor and populated asynchronously by the controller, since the
+ * files and their pending-change status require DB lookups.
+ */
+ licenseFiles: ProfileLicenseFileView[];
attributes?: Record | null;
+ /** Reviewer note when the role is rejected (drives the reapply prompt). */
+ reviewNote?: string | null;
createdAt: Date;
updatedAt: Date;
@@ -31,8 +37,9 @@ export class ResponseCompanyProfileDto {
this.reference = profile.reference ?? '';
this.status = profile.status;
this.businessLicense = profile.businessLicense;
- this.licenseFiles = profile.businessLicenseFiles ?? [];
+ this.licenseFiles = [];
this.attributes = profile.attributes;
+ this.reviewNote = profile.reviewNote ?? null;
this.createdAt = profile.createdAt;
this.updatedAt = profile.updatedAt;
}
diff --git a/apps/edr-freight-api/src/modules/companies/dto/update-company-profile-status.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/update-company-profile-status.dto.ts
index 96c02d846..83beb441f 100644
--- a/apps/edr-freight-api/src/modules/companies/dto/update-company-profile-status.dto.ts
+++ b/apps/edr-freight-api/src/modules/companies/dto/update-company-profile-status.dto.ts
@@ -1,9 +1,16 @@
-import { ApiProperty } from "@nestjs/swagger";
-import { IsIn } from "class-validator";
+import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
+import { IsIn, IsOptional, IsString, MaxLength } from "class-validator";
import { ProfileStatus } from "../entities/company-profile.entity";
export class UpdateCompanyProfileStatusDto {
@ApiProperty({ enum: ProfileStatus })
@IsIn(Object.values(ProfileStatus))
status!: ProfileStatus;
+
+ /** Reviewer note — required in practice when rejecting so the customer knows why. */
+ @ApiPropertyOptional()
+ @IsOptional()
+ @IsString()
+ @MaxLength(2000)
+ note?: string;
}
diff --git a/apps/edr-freight-api/src/modules/companies/entities/company-change-request.entity.ts b/apps/edr-freight-api/src/modules/companies/entities/company-change-request.entity.ts
new file mode 100644
index 000000000..cec670787
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/companies/entities/company-change-request.entity.ts
@@ -0,0 +1,87 @@
+import { BaseEntity } from "@edr/api-common";
+import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm";
+import { Company } from "./company.entity";
+
+/**
+ * Lifecycle of a customer's proposed profile change. Edits made on the portal
+ * settings page by an already-approved company are staged here (not written to
+ * the live Company row) until a backoffice reviewer approves — at which point
+ * the snapshot is applied — or rejects with a note, after which the customer can
+ * amend and resubmit.
+ */
+export enum ChangeRequestStatus {
+ Pending = "pending",
+ Approved = "approved",
+ Rejected = "rejected",
+}
+
+/**
+ * A single staged business-license change on one company profile, awaiting
+ * review. `add` → a new file was uploaded under the pending code and becomes
+ * live on approval; `remove` → an existing live file is deleted on approval.
+ * A "replace" is recorded as a `remove` of the old file plus an `add` of the
+ * new one. `fileId` is the FileRecord id the op targets.
+ */
+export interface LicenseChangeIntent {
+ profileId: string;
+ op: "add" | "remove";
+ fileId: string;
+ /** File name, snapshotted for the backoffice review screen. */
+ fileName?: string;
+}
+
+/** File references staged alongside a change request (documents/licenses). */
+export interface ChangeRequestDocuments {
+ /** FileRecord ids uploaded against the company while this request was open. */
+ documentFileIds?: string[];
+ /** Staged per-profile business-license add/remove intents. */
+ licenseChanges?: LicenseChangeIntent[];
+}
+
+@Entity({ schema: "freight", name: "company_change_request" })
+@Index(["companyId"])
+@Index(["status"])
+export class CompanyChangeRequest extends BaseEntity {
+ @Column({ name: "company_id", type: "uuid" })
+ companyId!: string;
+
+ @ManyToOne(() => Company, { onDelete: "CASCADE" })
+ @JoinColumn({ name: "company_id" })
+ company?: Company;
+
+ /**
+ * Proposed profile field values, shaped as `Partial`. Covers
+ * the Company / Contact / General Manager / Power-of-Attorney tabs (contact/GM/
+ * PoA fields land in `Company.attributes` on approval).
+ */
+ @Column({ name: "snapshot", type: "jsonb" })
+ snapshot!: Record;
+
+ /** Staged document/license file references (see {@link ChangeRequestDocuments}). */
+ @Column({ name: "documents", type: "jsonb", nullable: true })
+ documents?: ChangeRequestDocuments | null;
+
+ @Column({
+ name: "status",
+ type: "varchar",
+ length: 20,
+ default: ChangeRequestStatus.Pending,
+ })
+ status!: ChangeRequestStatus;
+
+ /** Backoffice reviewer's rejection note. */
+ @Column({ name: "note", type: "text", nullable: true })
+ note?: string | null;
+
+ @Column({ name: "submitted_by", type: "uuid", nullable: true })
+ submittedBy?: string | null;
+
+ @Column({ name: "submitted_at", type: "timestamptz", nullable: true })
+ submittedAt?: Date | null;
+
+ @Column({ name: "reviewed_by", type: "uuid", nullable: true })
+ reviewedBy?: string | null;
+
+ @Column({ name: "reviewed_at", type: "timestamptz", nullable: true })
+ reviewedAt?: Date | null;
+}
diff --git a/apps/edr-freight-api/src/modules/companies/entities/company-profile.entity.ts b/apps/edr-freight-api/src/modules/companies/entities/company-profile.entity.ts
index e61668a07..72696766f 100644
--- a/apps/edr-freight-api/src/modules/companies/entities/company-profile.entity.ts
+++ b/apps/edr-freight-api/src/modules/companies/entities/company-profile.entity.ts
@@ -13,11 +13,17 @@ export enum ProfileType {
export enum ProfileStatus {
Active = "active",
Pending = "pending",
+ /** Reviewer declined the role; carries a note. Customer can reapply → Pending. */
+ Rejected = "rejected",
Suspended = "suspended",
Blacklisted = "blacklisted",
}
-/** A business-license document stored directly on the company profile. */
+/**
+ * @deprecated Legacy inline shape. Business-license files now live in the
+ * FileRecord model (`freight.files`, resource `company_profiles`). Kept only for
+ * the by-reference reuse shape consumed by bookings/contracts snapshots.
+ */
export interface BusinessLicenseFile {
name: string;
url: string;
@@ -25,6 +31,19 @@ export interface BusinessLicenseFile {
mimeType?: string;
}
+/** A business-license file plus its change-review state, surfaced to clients. */
+export interface ProfileLicenseFileView {
+ id: string;
+ name: string;
+ size: number;
+ mimeType: string;
+ /**
+ * `live` — approved & in effect; `pending_add` — uploaded, awaiting approval;
+ * `pending_remove` — live but flagged for deletion on approval.
+ */
+ status: "live" | "pending_add" | "pending_remove";
+}
+
@Entity({ schema: "freight", name: "company_profiles" })
@Index(["reference"], { unique: true })
@Index(["type"])
@@ -80,4 +99,14 @@ export class CompanyProfile extends BaseEntity {
@Column({ name: "attributes", type: "jsonb", nullable: true })
attributes?: Record | null;
+
+ /** Reviewer's note when the role is Rejected (cleared on reapply). */
+ @Column({ name: "review_note", type: "text", nullable: true })
+ reviewNote?: string | null;
+
+ @Column({ name: "reviewed_by", type: "uuid", nullable: true })
+ reviewedBy?: string | null;
+
+ @Column({ name: "reviewed_at", type: "timestamptz", nullable: true })
+ reviewedAt?: Date | null;
}
diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.service.ts b/apps/edr-freight-api/src/modules/contracts/contracts.service.ts
index aaf064bff..095857959 100644
--- a/apps/edr-freight-api/src/modules/contracts/contracts.service.ts
+++ b/apps/edr-freight-api/src/modules/contracts/contracts.service.ts
@@ -12,7 +12,7 @@ import { YardCountry } from '@edr/types';
import { deriveTradeDirection } from '../../common/derive-trade-direction.util';
import { CompaniesService } from '../companies/companies.service';
-import { CompanyProfile, ProfileType } from '../companies/entities/company-profile.entity';
+import { ProfileType } from '../companies/entities/company-profile.entity';
import { CompanyStatus } from '../companies/entities/company.entity';
import { ServiceType } from '../rule-engine/entities/service-type.entity';
import { Yard } from '../rule-engine/entities/yard.entity';
@@ -326,10 +326,20 @@ export class ContractsService {
companyProfileId: string | null,
): Promise {
if (!companyProfileId) return;
- const profile = await this.dataSource
- .getRepository(CompanyProfile)
- .findOne({ where: { id: companyProfileId } });
- const docs = profile?.businessLicenseFiles ?? [];
+ // Business-license files are FileRecords (resource "company_profiles"); carry
+ // the live ones by reference. Staged/pending uploads are excluded by code.
+ const records = await this.filesService.findByResource(
+ companyProfileId,
+ 'company_profiles',
+ );
+ const docs = records
+ .filter((r) => r.code === 'business_license')
+ .map((r) => ({
+ name: r.name,
+ url: r.url,
+ size: r.size,
+ mimeType: r.mimeType,
+ }));
if (docs.length === 0) return;
const slug = (name: string) =>
diff --git a/apps/edr-freight-api/src/modules/contracts/dto/create-booking-under-contract.dto.ts b/apps/edr-freight-api/src/modules/contracts/dto/create-booking-under-contract.dto.ts
index e3130da95..f220cae0d 100644
--- a/apps/edr-freight-api/src/modules/contracts/dto/create-booking-under-contract.dto.ts
+++ b/apps/edr-freight-api/src/modules/contracts/dto/create-booking-under-contract.dto.ts
@@ -9,14 +9,19 @@ import {
IsOptional,
IsString,
IsUUID,
+ Matches,
Min,
ValidateNested,
} from 'class-validator';
/** One physical container under a booking line — entered at booking time. */
export class CreateContainerUnitDto {
- @ApiProperty()
+ @ApiProperty({ description: 'ISO 6346 container number, e.g. ABCD1234567' })
@IsString()
+ @Transform(({ value }) => (typeof value === 'string' ? value.trim().toUpperCase() : value))
+ @Matches(/^[A-Z]{4}\d{7}$/, {
+ message: 'containerNumber must match ISO container format, e.g. ABCD1234567',
+ })
containerNumber!: string;
@ApiPropertyOptional()
diff --git a/apps/edr-freight-api/src/modules/files/files.service.ts b/apps/edr-freight-api/src/modules/files/files.service.ts
index 4966d7ff9..3fd0752f7 100644
--- a/apps/edr-freight-api/src/modules/files/files.service.ts
+++ b/apps/edr-freight-api/src/modules/files/files.service.ts
@@ -124,6 +124,15 @@ export class FilesService {
await this.filesRepository.softDelete(id);
}
+ /**
+ * Re-slot a stored file under a new `code` (e.g. promote a staged
+ * `business_license_pending` file to the live `business_license` code once a
+ * change request is approved). Bytes and URL are untouched.
+ */
+ async setCode(id: string, code: string): Promise {
+ await this.filesRepository.update(id, { code });
+ }
+
findByResource(resourceId: string, resource: string): Promise {
return this.filesRepository.findByResource(resourceId, resource);
}
diff --git a/apps/edr-freight-api/src/modules/gps-tracking/gps-tracking.controller.ts b/apps/edr-freight-api/src/modules/gps-tracking/gps-tracking.controller.ts
index e380e541e..8bd4a31d8 100644
--- a/apps/edr-freight-api/src/modules/gps-tracking/gps-tracking.controller.ts
+++ b/apps/edr-freight-api/src/modules/gps-tracking/gps-tracking.controller.ts
@@ -11,14 +11,15 @@ import {
} from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
-import { FleetManage, FleetView } from '../../common/booking-guards';
+import { BookingStaff } from '../../common/booking-guards';
+import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
import { GpsTrackingService } from './gps-tracking.service';
import { RegisterDeviceDto, UpdateDeviceDto } from './dto/gps-device.dto';
@ApiTags('gps-tracking')
@ApiBearerAuth()
@Controller('gps')
-@FleetView()
+@BookingStaff(FREIGHT_PERMS.tracking.view)
export class GpsTrackingController {
constructor(private readonly gps: GpsTrackingService) {}
@@ -44,21 +45,21 @@ export class GpsTrackingController {
}
@Post('devices')
- @FleetManage()
+ @BookingStaff(FREIGHT_PERMS.tracking.manage)
@ApiOperation({ summary: 'Register a GPS tracker' })
register(@Body() dto: RegisterDeviceDto) {
return this.gps.registerDevice(dto);
}
@Patch('devices/:id')
- @FleetManage()
+ @BookingStaff(FREIGHT_PERMS.tracking.manage)
@ApiOperation({ summary: 'Update a GPS tracker (name / assigned vehicle)' })
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateDeviceDto) {
return this.gps.updateDevice(id, dto);
}
@Delete('devices/:id')
- @FleetManage()
+ @BookingStaff(FREIGHT_PERMS.tracking.manage)
@ApiOperation({ summary: 'Delete a GPS tracker' })
remove(@Param('id', ParseUUIDPipe) id: string) {
return this.gps.removeDevice(id);
diff --git a/apps/edr-freight-api/src/modules/notifications/notify-company.util.ts b/apps/edr-freight-api/src/modules/notifications/notify-company.util.ts
new file mode 100644
index 000000000..9d7f32c3d
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/notifications/notify-company.util.ts
@@ -0,0 +1,37 @@
+import { DataSource } from 'typeorm';
+
+import { NotificationsService } from './notifications.service';
+
+/**
+ * Best-effort SMS + email fan-out to a company's contacts. Looks up the
+ * company's phone/email and sends the message over both channels, swallowing
+ * per-channel failures so a missing provider never breaks the caller's flow.
+ */
+export async function sendCompanyChannels(
+ dataSource: DataSource,
+ notifications: NotificationsService,
+ companyId: string,
+ message: string,
+): Promise {
+ const [contact]: Array<{ phone: string | null; email: string | null }> =
+ await dataSource.query(
+ `SELECT COALESCE(phone, etrade_phone) AS phone, email
+ FROM freight.companies
+ WHERE id = $1 AND deleted_at IS NULL`,
+ [companyId],
+ );
+ if (contact?.phone) {
+ try {
+ await notifications.directSend('sms', contact.phone, message);
+ } catch {
+ /* best-effort: SMS provider unavailable */
+ }
+ }
+ if (contact?.email) {
+ try {
+ await notifications.directSend('email', contact.email, message);
+ } catch {
+ /* best-effort: email provider unavailable */
+ }
+ }
+}
diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.ts
index f1f63802e..48985bdf0 100644
--- a/apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.ts
+++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.ts
@@ -71,6 +71,24 @@ export class BookingNotifierService {
});
}
+ /** Train carrying the booking departed — dispatched origin → destination. */
+ dispatched(b: Booking, origin: string | null, destination: string | null): void {
+ const msg =
+ `Your booking ${b.reference ?? b.id} has been dispatched` +
+ `${origin || destination ? ` from ${origin ?? '?'} to ${destination ?? '?'}` : ''}.`;
+ void this.notifyContact(b, msg, 'DISPATCHED');
+ this.inApp(b, 'Shipment dispatched', msg);
+ }
+
+ /** Train carrying the booking arrived at destination. */
+ arrived(b: Booking, origin: string | null, destination: string | null): void {
+ const msg =
+ `Your booking ${b.reference ?? b.id} has arrived` +
+ `${destination ? ` at ${destination}` : ''}${origin ? ` (from ${origin})` : ''}.`;
+ void this.notifyContact(b, msg, 'ARRIVED');
+ this.inApp(b, 'Shipment arrived', msg);
+ }
+
async payNow(b: Booking, deadline: Date): Promise {
const payMinutes = Math.max(1, Math.round((deadline.getTime() - Date.now()) / 60_000));
const eat = deadline.toLocaleString('en-GB', { timeZone: 'Africa/Addis_Ababa' });
diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts
index 7efbceca4..b7938857f 100644
--- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts
+++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts
@@ -156,6 +156,7 @@ describe('TrainSchedulingService', () => {
{
autoArriveAtFinalYard: jest.fn().mockResolvedValue([]),
} as never, // bookingJourneyService
+ { dispatched: jest.fn(), arrived: jest.fn() } as never, // bookingNotifier
);
const defaultFleetWagons = [
diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts
index 3c75204a6..f858c6c14 100644
--- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts
+++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts
@@ -72,6 +72,7 @@ import { UpdateScheduleWindowRuleDto } from './dto/update-schedule-window-rule.d
import { UpdateScheduleDateDto } from './dto/update-schedule-date.dto';
import { type BookingWindowConfig } from './booking-window.config';
import { BookingWindowGateway } from './booking-window.gateway';
+import { BookingNotifierService } from './booking-notifier.service';
import {
buildCappedWagonPlan,
computeFleetAvailability,
@@ -281,10 +282,38 @@ export class TrainSchedulingService {
private readonly pdfDocuments: WarehouseReleaseDocumentService,
private readonly bookingWindowGateway: BookingWindowGateway,
private readonly bookingJourneyService: BookingJourneyService,
+ private readonly bookingNotifier: BookingNotifierService,
@Optional() private readonly milestoneService?: ClearanceMilestoneService,
private readonly configService?: ConfigService,
) {}
+ /**
+ * Notify each booking's customer that their shipment was dispatched / arrived,
+ * with a deep-link to the booking. Fire-and-forget — never blocks the action.
+ */
+ private async notifyScheduleBookings(
+ schedule: TrainSchedule,
+ event: 'dispatched' | 'arrived',
+ ): Promise {
+ try {
+ const ids = (schedule.scheduleBookings ?? []).map((sb) => sb.bookingId).filter(Boolean);
+ if (!ids.length) return;
+ const origin = schedule.originStation?.label ?? schedule.originStation?.code ?? null;
+ const destination =
+ schedule.destinationStation?.label ?? schedule.destinationStation?.code ?? null;
+ const bookings = await this.dataSource.getRepository(Booking).find({
+ where: { id: In(ids) },
+ relations: { company: true },
+ });
+ for (const b of bookings) {
+ if (event === 'dispatched') this.bookingNotifier.dispatched(b, origin, destination);
+ else this.bookingNotifier.arrived(b, origin, destination);
+ }
+ } catch (err) {
+ this.logger.warn(`Failed to notify schedule bookings (${event}): ${(err as Error).message}`);
+ }
+ }
+
/**
* Complete customer-tracking clearance milestones for every booking on a
* schedule when a physical lifecycle event fires (dispatch, arrive, load,
@@ -1551,6 +1580,7 @@ export class TrainSchedulingService {
{ originYardId: schedule.originStationId },
);
}
+ void this.notifyScheduleBookings(schedule, 'dispatched');
return this.getTrainScheduleById(scheduleId);
}
@@ -2556,6 +2586,7 @@ export class TrainSchedulingService {
{ destinationYardId: schedule.destinationStationId },
);
}
+ void this.notifyScheduleBookings(schedule, 'arrived');
const detail = await this.getTrainScheduleById(scheduleId);
const warehouseAutomation = await this.runWarehouseArrivalAutomation(scheduleId);
diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/create-warehouse-yard.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/create-warehouse-yard.dto.ts
index ccdda90d8..56b9d0810 100644
--- a/apps/edr-freight-api/src/modules/warehouses/dto/create-warehouse-yard.dto.ts
+++ b/apps/edr-freight-api/src/modules/warehouses/dto/create-warehouse-yard.dto.ts
@@ -35,7 +35,7 @@ export class CreateWarehouseYardDto {
@Min(0)
capacityContainers?: number;
- @ApiPropertyOptional({ description: 'Max weight capacity (kg). Defaults to capacityWeight.' })
+ @ApiPropertyOptional({ description: 'Max weight capacity (t). Defaults to capacityWeight.' })
@IsOptional()
@IsNumber()
@Min(0)
diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/create-warehouse-zone.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/create-warehouse-zone.dto.ts
index fbb057fd5..eb29f751f 100644
--- a/apps/edr-freight-api/src/modules/warehouses/dto/create-warehouse-zone.dto.ts
+++ b/apps/edr-freight-api/src/modules/warehouses/dto/create-warehouse-zone.dto.ts
@@ -35,7 +35,7 @@ export class CreateWarehouseZoneDto {
@Min(0)
capacityContainers?: number;
- @ApiPropertyOptional({ description: 'Max weight capacity (kg). Defaults to capacityWeight.' })
+ @ApiPropertyOptional({ description: 'Max weight capacity (t). Defaults to capacityWeight.' })
@IsOptional()
@IsNumber()
@Min(0)
diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/create-warehouse.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/create-warehouse.dto.ts
index 5a8025948..a99ca4f46 100644
--- a/apps/edr-freight-api/src/modules/warehouses/dto/create-warehouse.dto.ts
+++ b/apps/edr-freight-api/src/modules/warehouses/dto/create-warehouse.dto.ts
@@ -1,7 +1,7 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsEnum, IsNumber, IsOptional, IsString, IsUUID, Matches, MaxLength, Min } from 'class-validator';
-import { WAREHOUSE_TYPES, WarehouseType } from '../entities/warehouse.entity';
+import { WAREHOUSE_STATUSES, WAREHOUSE_TYPES, WarehouseStatus, WarehouseType } from '../entities/warehouse.entity';
export class CreateWarehouseDto {
@ApiProperty()
@@ -47,7 +47,7 @@ export class CreateWarehouseDto {
@Min(0)
capacityContainers?: number;
- @ApiPropertyOptional({ description: 'Max weight capacity (kg). Defaults to capacityWeight.' })
+ @ApiPropertyOptional({ description: 'Max weight capacity (t). Defaults to capacityWeight.' })
@IsOptional()
@IsNumber()
@Min(0)
@@ -58,4 +58,9 @@ export class CreateWarehouseDto {
@IsNumber()
@Min(0)
maxVolume?: number;
+
+ @ApiPropertyOptional({ enum: WAREHOUSE_STATUSES, default: 'ACTIVE' })
+ @IsOptional()
+ @IsEnum(WAREHOUSE_STATUSES)
+ status?: WarehouseStatus;
}
diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/load-inventory.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/load-inventory.dto.ts
index 063bb7d1d..c81550cd0 100644
--- a/apps/edr-freight-api/src/modules/warehouses/dto/load-inventory.dto.ts
+++ b/apps/edr-freight-api/src/modules/warehouses/dto/load-inventory.dto.ts
@@ -6,7 +6,7 @@ export class LoadInventoryDto {
@IsUUID()
wagonId!: string;
- @ApiPropertyOptional({ description: 'Weight loaded onto the wagon (kg)' })
+ @ApiPropertyOptional({ description: 'Weight loaded onto the wagon (t)' })
@IsOptional()
@IsNumber()
@Min(0)
diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/store-inventory.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/store-inventory.dto.ts
new file mode 100644
index 000000000..08b15d536
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/warehouses/dto/store-inventory.dto.ts
@@ -0,0 +1,29 @@
+import { ApiPropertyOptional } from '@nestjs/swagger';
+import { IsOptional, IsString, IsUUID } from 'class-validator';
+
+/**
+ * Optional explicit storage location. When warehouse/yard/zone are all provided,
+ * the item is stored there directly; otherwise store() falls back to the
+ * allocation-rule / capacity-balanced auto pick.
+ */
+export class StoreInventoryDto {
+ @ApiPropertyOptional({ format: 'uuid' })
+ @IsOptional()
+ @IsUUID()
+ warehouseId?: string;
+
+ @ApiPropertyOptional({ format: 'uuid' })
+ @IsOptional()
+ @IsUUID()
+ yardId?: string;
+
+ @ApiPropertyOptional({ format: 'uuid' })
+ @IsOptional()
+ @IsUUID()
+ zoneId?: string;
+
+ @ApiPropertyOptional()
+ @IsOptional()
+ @IsString()
+ performedBy?: string;
+}
diff --git a/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-inventory.entity.ts b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-inventory.entity.ts
index 290b6f0c2..a54f40973 100644
--- a/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-inventory.entity.ts
+++ b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-inventory.entity.ts
@@ -34,7 +34,9 @@ export const WAREHOUSE_INVENTORY_TRANSITIONS: Record {
+ try {
+ const [b]: Array<{ companyId: string | null; reference: string }> = await this.dataSource.query(
+ `SELECT company_id AS "companyId", reference FROM freight.bookings WHERE id = $1 AND deleted_at IS NULL`,
+ [bookingId],
+ );
+ if (!b?.companyId) return;
+ const body = `Your import handover ${reference} for booking ${b.reference} is ready. Please review and sign it from the portal before the truck leaves.`;
+ await this.inbox.notify({
+ recipients: { companyId: b.companyId },
+ audience: NotificationAudience.PORTAL,
+ type: NotificationType.DOCUMENT_ACTION,
+ title: 'Handover — signature needed',
+ body,
+ link: `/bookings/${bookingId}`,
+ data: { bookingId, reference },
+ });
+ await sendCompanyChannels(this.dataSource, this.notifications, b.companyId, body);
+ } catch (err) {
+ this.logger.warn(`Failed to notify handover sign for ${bookingId}: ${(err as Error).message}`);
+ }
+ }
list(bookingId: string): Promise {
return this.dataSource.getRepository(BookingHandover).find({
@@ -22,6 +54,32 @@ export class HandoverService {
});
}
+ /**
+ * Ask the customer to sign the booking's handover. Ensures a handover exists
+ * (creates a booking-level self-haul one if none yet), then fires the
+ * sign-needed notification (in-app + SMS + email). Idempotent to re-send.
+ */
+ async requestSignature(
+ bookingId: string,
+ ): Promise<{ notified: boolean; reference: string | null; alreadySigned: boolean }> {
+ const repo = this.dataSource.getRepository(BookingHandover);
+ const existing = await repo.find({ where: { bookingId }, order: { generatedAt: 'ASC' } });
+
+ if (existing.length === 0) {
+ // No handover yet (truck not arrived): create a booking-level one so the
+ // customer has something to sign. ensureForArrivedTruck notifies on create.
+ const created = await this.ensureForArrivedTruck(bookingId, {});
+ return { notified: true, reference: created.reference, alreadySigned: false };
+ }
+
+ const unsigned = existing.find((h) => !h.signedAt);
+ if (!unsigned) {
+ return { notified: false, reference: existing[0].reference, alreadySigned: true };
+ }
+ await this.notifySignNeeded(bookingId, unsigned.reference);
+ return { notified: true, reference: unsigned.reference, alreadySigned: false };
+ }
+
/**
* Self-haul: ensure a handover exists for a customer truck that just arrived.
* Idempotent — one per (booking, truck). Runs inside the caller's transaction
@@ -54,6 +112,7 @@ export class HandoverService {
}),
);
this.logger.log(`Handover ${reference} generated on arrival for booking ${bookingId}`);
+ void this.notifySignNeeded(bookingId, reference);
return saved;
}
diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inspection.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inspection.service.ts
index ff25258d3..b5894c21b 100644
--- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inspection.service.ts
+++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inspection.service.ts
@@ -1,8 +1,13 @@
-import { Injectable, NotFoundException } from '@nestjs/common';
+import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import { DataSource } from 'typeorm';
+import { NotificationAudience, NotificationType } from '@edr/types';
+
import { FilesService } from '../files/files.service';
import { LastMileService } from '../last-mile/last-mile.service';
+import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
+import { NotificationsService } from '../notifications/notifications.service';
+import { sendCompanyChannels } from '../notifications/notify-company.util';
import { CreateInspectionReportDto } from './dto/create-inspection-report.dto';
import { UpdateInspectionReportDto } from './dto/update-inspection-report.dto';
import { WarehouseInspectionReport } from './entities/warehouse-inspection-report.entity';
@@ -13,11 +18,15 @@ const INSPECTION_RESOURCE = 'warehouse-inspection-report';
@Injectable()
export class WarehouseInspectionService {
+ private readonly logger = new Logger(WarehouseInspectionService.name);
+
constructor(
private readonly dataSource: DataSource,
private readonly inspectionRepository: WarehouseInspectionRepository,
private readonly filesService: FilesService,
private readonly lastMileService: LastMileService,
+ private readonly inbox: NotificationInboxService,
+ private readonly notifications: NotificationsService,
) {}
/** Create or update the inspection report for an inventory item and sync its inspectionStatus. */
@@ -44,7 +53,7 @@ export class WarehouseInspectionService {
expectedWeight: expected,
actualWeight: actual,
weightLoss,
- weightLossUnit: weightLoss !== null ? 'kg' : null,
+ weightLossUnit: weightLoss !== null ? 't' : null,
hasMissingItems: dto.hasMissingItems ?? false,
missingItemsDescription: dto.missingItemsDescription ?? null,
remarks: dto.remarks ?? null,
@@ -83,8 +92,10 @@ export class WarehouseInspectionService {
const [row] = await this.dataSource.query(
`SELECT inv.booking_id AS "bookingId",
b.reference AS "bookingReference",
+ b.company_id AS "companyId",
b.trade_direction AS "tradeDirection",
b.last_mile_delivery_address AS "lastMileDeliveryAddress",
+ b.customer_truck_assigned_at AS "customerTruckAssignedAt",
COALESCE(st.includes_last_mile, false) AS "serviceIncludesLastMile"
FROM freight.warehouse_inventory inv
LEFT JOIN freight.bookings b ON b.id = inv.booking_id
@@ -105,6 +116,36 @@ export class WarehouseInspectionService {
if (row.bookingReference && hasLastMile) {
await this.lastMileService.acceptBooking(row.bookingReference);
+ } else if (!hasLastMile && !row.customerTruckAssignedAt) {
+ // Self-haul import: goods are pickup-ready but no collection truck is
+ // assigned yet — nudge the customer to assign one from the portal.
+ void this.notifyTruckAssignmentNeeded(row);
+ }
+ }
+
+ /** Portal nudge: import goods are ready for pickup but no customer truck is assigned. */
+ private async notifyTruckAssignmentNeeded(row: {
+ bookingId?: string | null;
+ bookingReference?: string | null;
+ companyId?: string | null;
+ }): Promise {
+ if (!row.companyId || !row.bookingId) return;
+ const body = `Booking ${row.bookingReference ?? row.bookingId} has passed inspection and is ready for pickup. Please assign your collection truck(s) from the portal to proceed.`;
+ try {
+ await this.inbox.notify({
+ recipients: { companyId: row.companyId },
+ audience: NotificationAudience.PORTAL,
+ type: NotificationType.BOOKING_STATUS,
+ title: 'Assign a truck for pickup',
+ body,
+ link: `/bookings/${row.bookingId}`,
+ data: { bookingId: row.bookingId, action: 'ASSIGN_TRUCK' },
+ });
+ await sendCompanyChannels(this.dataSource, this.notifications, row.companyId, body);
+ } catch (err) {
+ this.logger.warn(
+ `Truck-assignment notify failed for ${row.bookingId}: ${(err as Error).message}`,
+ );
}
}
diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts
index 84baaf4da..242cecc76 100644
--- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts
+++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts
@@ -9,6 +9,7 @@ import { FilterWarehouseInventoryDto } from './dto/filter-inventory.dto';
import { InquiryWarehouseInventoryDto } from './dto/inquiry-inventory.dto';
import { LoadInventoryDto } from './dto/load-inventory.dto';
import { MoveInventoryDto } from './dto/move-inventory.dto';
+import { StoreInventoryDto } from './dto/store-inventory.dto';
import { ReceiveWarehouseInventoryDto } from './dto/receive-inventory.dto';
import { ReleaseOrderDto } from './dto/release-order.dto';
import { ReserveInventoryDto } from './dto/reserve-inventory.dto';
@@ -267,9 +268,9 @@ export class WarehouseInventoryController {
}
@Post(':id/store')
- @ApiOperation({ summary: 'Mark received inventory as STORED' })
- store(@Param('id', ParseUUIDPipe) id: string, @Body('performedBy') performedBy?: string) {
- return this.inventoryService.store(id, performedBy);
+ @ApiOperation({ summary: 'Mark received inventory as STORED (optional explicit warehouse/yard/zone)' })
+ store(@Param('id', ParseUUIDPipe) id: string, @Body() dto: StoreInventoryDto) {
+ return this.inventoryService.store(id, dto.performedBy, dto);
}
@Post(':id/ready-for-loading')
@@ -354,12 +355,34 @@ export class WarehouseInventoryController {
return this.handoverService.list(bookingId);
}
+ @Post('bookings/:bookingId/request-handover-signature')
+ @ApiOperation({ summary: 'Ask the customer to sign the handover (creates one if none, then notifies)' })
+ requestHandoverSignature(@Param('bookingId', ParseUUIDPipe) bookingId: string) {
+ return this.handoverService.requestSignature(bookingId);
+ }
+
+ @Get('bookings/:bookingId/handover-document')
+ @ApiOperation({ summary: 'View import goods handover document PDF (resolved by booking)' })
+ async bookingHandoverDocument(@Param('bookingId', ParseUUIDPipe) bookingId: string, @Res() res: Response) {
+ const { filename, buffer } = await this.inventoryService.handoverDocumentForBooking(bookingId);
+ res.setHeader('Content-Type', 'application/pdf');
+ res.setHeader('Content-Disposition', `inline; filename="${filename}"`);
+ res.setHeader('Content-Length', buffer.length);
+ return res.send(buffer);
+ }
+
@Get('bookings/:bookingId/container-items')
@ApiOperation({ summary: 'Per-container/bulk items of a booking with lifecycle stage + refs' })
containerItems(@Param('bookingId', ParseUUIDPipe) bookingId: string) {
return this.inventoryService.containerItems(bookingId);
}
+ @Get('bookings/:bookingId/container-weights')
+ @ApiOperation({ summary: "A booking's containers + VGM cargo weight (tonnes) for exit weighing" })
+ containerWeights(@Param('bookingId', ParseUUIDPipe) bookingId: string) {
+ return this.inventoryService.bookingContainerWeights(bookingId);
+ }
+
@Post(':id/deliver')
@ApiOperation({ summary: 'Deliver import goods to the customer + capture proof of delivery' })
deliver(@Param('id', ParseUUIDPipe) id: string, @Body() dto: DeliverInventoryDto) {
diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts
index d2562fe93..9b2f53a99 100644
--- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts
+++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts
@@ -7,6 +7,7 @@ import { InterchangeDocumentsService } from '../interchange-documents/interchang
import type { InterchangeDocument } from '../interchange-documents/entities/interchange-document.entity';
import { LastMileService } from '../last-mile/last-mile.service';
import { NotificationsService } from '../notifications/notifications.service';
+import { sendCompanyChannels } from '../notifications/notify-company.util';
import { SignaturesService } from '../signatures/signatures.service';
import { BulkInspectDto } from './dto/bulk-inspect.dto';
import { BulkReceiveDto, TruckEntranceDto } from './dto/bulk-receive.dto';
@@ -39,6 +40,8 @@ import { WarehouseInventoryRepository } from './warehouse-inventory.repository';
import { WarehouseLoadingRepository } from './warehouse-loading.repository';
import { WarehouseReleaseDocumentService } from './warehouse-release-document.service';
import { HandoverService } from './handover.service';
+import { NotificationAudience, NotificationType } from '@edr/types';
+import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
/** Wagon states that may receive a load (besides being part of an existing schedule). */
const LOADABLE_WAGON_STATUSES = ['AVAILABLE', 'IMPORT_READY', 'EXPORT_READY', 'ASSIGNED'];
@@ -356,12 +359,14 @@ export interface ImportUnloadedRow {
customerTruckType: string | null;
customerTruckContainerNumber: string | null;
customerTruckAssignedAt: string | null;
+ hasAssignedTruck: boolean;
currentStatus: string;
releaseDate: string | null;
releaseOrderReference: string | null;
handoverDocumentReference: string | null;
handoverDocumentDate: string | null;
deliveredAt: string | null;
+ notes: string | null;
}
@Injectable()
@@ -383,8 +388,41 @@ export class WarehouseInventoryService {
private readonly notifications: NotificationsService,
private readonly signatures: SignaturesService,
private readonly handover: HandoverService,
+ private readonly inbox: NotificationInboxService,
) {}
+ /**
+ * When a self-haul booking (no EDR first/last mile) is received to the warehouse
+ * but has no customer truck assigned yet, nudge the customer to assign one — with
+ * a deep-link to the booking's truck-assignment card. Fire-and-forget.
+ */
+ private async notifyTruckAssignmentNeeded(booking: {
+ companyId?: string | null;
+ reference?: string | null;
+ hasFirstMile?: boolean;
+ hasLastMile?: boolean;
+ customerTruckAssignedAt?: string | null;
+ }, bookingId: string): Promise {
+ if (!booking.companyId) return;
+ if (booking.hasFirstMile || booking.hasLastMile) return; // EDR mile — no customer truck
+ if (booking.customerTruckAssignedAt) return; // already assigned
+ const body = `Booking ${booking.reference ?? bookingId} has been received at the warehouse. Please assign your collection truck(s) from the portal to proceed.`;
+ try {
+ await this.inbox.notify({
+ recipients: { companyId: booking.companyId },
+ audience: NotificationAudience.PORTAL,
+ type: NotificationType.BOOKING_STATUS,
+ title: 'Assign a truck for pickup',
+ body,
+ link: `/bookings/${bookingId}`,
+ data: { bookingId, action: 'ASSIGN_TRUCK' },
+ });
+ await sendCompanyChannels(this.dataSource, this.notifications, booking.companyId, body);
+ } catch (err) {
+ this.logger.warn(`Truck-assignment notify failed for ${bookingId}: ${(err as Error).message}`);
+ }
+ }
+
/**
* Batch 6 — final terminal release / gate clearance.
* Blocked while an unpaid demurrage/storage invoice exists. Does NOT touch
@@ -866,7 +904,10 @@ export class WarehouseInventoryService {
b.customer_truck_driver_name AS "customerTruckDriverName",
b.customer_truck_type AS "customerTruckType",
b.customer_truck_container_number AS "customerTruckContainerNumber",
- b.customer_truck_assigned_at AS "customerTruckAssignedAt"
+ b.customer_truck_assigned_at AS "customerTruckAssignedAt",
+ b.company_id AS "companyId",
+ (NULLIF(TRIM(COALESCE(b.last_mile_delivery_address, '')), '') IS NOT NULL
+ OR COALESCE(st.includes_last_mile, false)) AS "hasLastMile"
FROM freight.bookings b
LEFT JOIN freight.companies company ON company.id = b.company_id
LEFT JOIN freight.yards oy ON oy.id = b.origin_yard_id
@@ -1002,6 +1043,7 @@ export class WarehouseInventoryService {
result.receivedCount += 1;
result.results.push({ bookingId, status: 'RECEIVED', inventoryId: saved.id, grnNumber });
+ void this.notifyTruckAssignmentNeeded(booking, bookingId);
}
});
@@ -1301,12 +1343,18 @@ export class WarehouseInventoryService {
b.customer_truck_type AS "customerTruckType",
b.customer_truck_container_number AS "customerTruckContainerNumber",
b.customer_truck_assigned_at AS "customerTruckAssignedAt",
+ (b.customer_truck_assigned_at IS NOT NULL
+ OR EXISTS (SELECT 1 FROM freight.last_mile lm
+ WHERE lm.booking_id = b.id
+ AND lm.vehicle_id IS NOT NULL
+ AND lm.deleted_at IS NULL)) AS "hasAssignedTruck",
inv.status AS "currentStatus",
inv.release_date AS "releaseDate",
inv.release_order_reference AS "releaseOrderReference",
substring(inv.notes FROM 'Handover Reference: ([^\\n\\r]+)') AS "handoverDocumentReference",
substring(inv.notes FROM 'Generated At: ([^\\n\\r]+)') AS "handoverDocumentDate",
inv.delivered_at AS "deliveredAt",
+ inv.notes AS "notes",
oy.country AS "originCountry",
dy.country AS "destinationCountry"
FROM freight.warehouse_inventory inv
@@ -2020,7 +2068,7 @@ export class WarehouseInventoryService {
activityType: 'INVENTORY_RECEIVED',
inventoryId: saved.id,
warehouseId: dto.warehouseId,
- description: `GRN ${grnNumber}: received ${weight}kg via truck ${truckEntrance.truckPlateNumber}`,
+ description: `GRN ${grnNumber}: received ${weight}t via truck ${truckEntrance.truckPlateNumber}`,
performedBy: dto.performedBy,
},
manager,
@@ -2104,13 +2152,30 @@ export class WarehouseInventoryService {
// ── Lifecycle transitions ────────────────────────────────────────────────
- async store(id: string, performedBy?: string): Promise {
+ async store(
+ id: string,
+ performedBy?: string,
+ chosen?: { warehouseId?: string; yardId?: string; zoneId?: string },
+ ): Promise {
const item = await this.findById(id);
this.assertTransition(item.status, 'STORED');
+ // Explicit location wins when the operator picked warehouse + yard + zone;
+ // otherwise fall back to the allocation-rule / capacity-balanced auto pick.
+ const manualLocation =
+ chosen?.warehouseId && chosen?.yardId && chosen?.zoneId
+ ? {
+ warehouseId: chosen.warehouseId,
+ yardId: chosen.yardId,
+ zoneId: chosen.zoneId,
+ path: undefined as string | undefined,
+ }
+ : null;
+
const criteria = await this.getInventoryAllocationCriteria(item);
- const ruleLocation = await this.allocation.resolveLocation(criteria);
- const location = ruleLocation ?? (await this.pickCapacityBalancedStorageLocation(item, criteria));
+ const ruleLocation = manualLocation ? null : await this.allocation.resolveLocation(criteria);
+ const location =
+ manualLocation ?? ruleLocation ?? (await this.pickCapacityBalancedStorageLocation(item, criteria));
if (!location) {
throw new BadRequestException('No active warehouse yard/zone is available for this inventory item');
@@ -2154,18 +2219,19 @@ export class WarehouseInventoryService {
await this.applyCapacityDelta(manager, location, weight, volume, containerCount);
}
+ const storedReason = manualLocation
+ ? `Stored at operator-selected location -> ${location.path ?? 'chosen yard/zone'}`
+ : ruleLocation?.rule
+ ? `Stored by allocation rule "${ruleLocation.rule.name}" -> ${ruleLocation.path}`
+ : `Stored by capacity-balanced allocation -> ${location.path ?? 'assigned yard/zone'}`;
+
await manager.getRepository(WarehouseInventory).update(id, {
status: 'STORED',
storedAt: new Date(),
warehouseId: location.warehouseId,
yardId: location.yardId,
zoneId: location.zoneId,
- notes: this.appendNote(
- locked.notes,
- ruleLocation?.rule
- ? `Stored by allocation rule "${ruleLocation.rule.name}" -> ${ruleLocation.path}`
- : `Stored by capacity-balanced allocation -> ${location.path ?? 'assigned yard/zone'}`,
- ),
+ notes: this.appendNote(locked.notes, storedReason),
});
await this.activityLog.record(
@@ -2173,9 +2239,7 @@ export class WarehouseInventoryService {
activityType: 'INVENTORY_STORED',
inventoryId: id,
warehouseId: location.warehouseId,
- description: ruleLocation?.rule
- ? `Inventory stored by rule "${ruleLocation.rule.name}" at ${ruleLocation.path}`
- : `Inventory stored at ${location.path ?? 'assigned yard/zone'}`,
+ description: storedReason.replace(/^Stored/, 'Inventory stored'),
performedBy,
},
manager,
@@ -2294,6 +2358,26 @@ export class WarehouseInventoryService {
'Customer must sign the handover before the exit paper can be generated',
);
}
+
+ // Authoritative weight match: the truck's net (gross − tare) must equal the
+ // total VGM cargo weight of the containers selected as loaded on it.
+ if (dto.containerNumber && dto.grossWeight != null && dto.tareWeight != null) {
+ const selected = dto.containerNumber
+ .split(/[,;\n]+/)
+ .map((n) => n.trim())
+ .filter(Boolean);
+ if (selected.length) {
+ const weights = await this.bookingContainerWeights(item.bookingId);
+ const byNumber = new Map(weights.map((w) => [w.containerNumber.toUpperCase(), w.weightTons]));
+ const expected = selected.reduce((sum, n) => sum + (byNumber.get(n.toUpperCase()) ?? 0), 0);
+ const computedNet = Number((dto.grossWeight - dto.tareWeight).toFixed(3));
+ if (expected > 0 && Math.abs(computedNet - expected) > 0.001) {
+ throw new BadRequestException(
+ `Weight mismatch: gross − tare (${computedNet} t) must equal the selected containers' cargo weight (${expected} t).`,
+ );
+ }
+ }
+ }
}
}
const releaseDate = isTruckLeaving
@@ -2519,15 +2603,17 @@ export class WarehouseInventoryService {
Array<{
containerNumber: string;
goods: string | null;
- stage: 'PENDING' | 'RECEIVED' | 'GRN' | 'LOADED' | 'LEFT' | 'DELIVERED';
+ stage: 'PENDING' | 'RECEIVED' | 'GRN' | 'ASSIGNED' | 'LOADED' | 'LEFT' | 'DELIVERED';
grnNumber: string | null;
truckAssignmentId: string | null;
truckPlate: string | null;
truckArrived: boolean;
truckLeft: boolean;
+ loaded: boolean;
bookingReference: string | null;
contractId: string | null;
hasLastMile: boolean;
+ handoverSigned: boolean;
}>
> {
const rows: Array<{
@@ -2539,6 +2625,7 @@ export class WarehouseInventoryService {
truckPlate: string | null;
truckArrived: boolean;
truckLeft: boolean;
+ loaded: boolean;
bookingReference: string | null;
contractId: string | null;
hasLastMile: boolean;
@@ -2552,6 +2639,7 @@ export class WarehouseInventoryService {
a.plate_number AS "truckPlate",
(a.arrived_at IS NOT NULL) AS "truckArrived",
(a.departed_at IS NOT NULL) AS "truckLeft",
+ (ctc.loaded_at IS NOT NULL) AS loaded,
b.reference AS "bookingReference",
b.contract_id AS "contractId",
(b.last_mile_delivery_address IS NOT NULL) AS "hasLastMile",
@@ -2574,28 +2662,64 @@ export class WarehouseInventoryService {
[bookingId],
);
+ // Booking-level gate: the per-truck exit paper is blocked until the handover
+ // is fully signed, so the UI can disable "Exit Paper" with a clear reason.
+ const handoverSigned = await this.handover.isFullySigned(bookingId);
+
return rows.map((r) => ({
containerNumber: r.containerNumber,
goods: r.goods,
+ // A container the customer assigned to a truck is ASSIGNED (planned); it
+ // only becomes LOADED once the operator loads it (loaded_at) on truck
+ // leaving. Departed → LEFT, delivered → DELIVERED.
stage: r.delivered
? 'DELIVERED'
: r.truckLeft
? 'LEFT'
- : r.truckAssignmentId
+ : r.loaded
? 'LOADED'
- : r.grnNumber
- ? 'GRN'
- : r.received
- ? 'RECEIVED'
- : 'PENDING',
+ : r.truckAssignmentId
+ ? 'ASSIGNED'
+ : r.grnNumber
+ ? 'GRN'
+ : r.received
+ ? 'RECEIVED'
+ : 'PENDING',
grnNumber: r.grnNumber,
truckAssignmentId: r.truckAssignmentId,
truckPlate: r.truckPlate,
truckArrived: r.truckArrived,
truckLeft: r.truckLeft,
+ loaded: r.loaded,
bookingReference: r.bookingReference,
contractId: r.contractId,
hasLastMile: r.hasLastMile,
+ handoverSigned,
+ }));
+ }
+
+ /**
+ * The booking's containers with their VGM cargo weight (tonnes), keyed by
+ * container number. Drives the truck-leaving exit weighing: the selected
+ * containers' total cargo weight must match (gross − tare).
+ */
+ async bookingContainerWeights(
+ bookingId: string,
+ ): Promise> {
+ const rows: Array<{ containerNumber: string; weightTons: string }> =
+ await this.dataSource.query(
+ `SELECT bcu.container_number AS "containerNumber",
+ COALESCE(bcu.vgm_tons, 0) AS "weightTons"
+ 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
+ ORDER BY bcu.container_number`,
+ [bookingId],
+ );
+ return rows.map((r) => ({
+ containerNumber: r.containerNumber,
+ weightTons: Number(r.weightTons) || 0,
}));
}
@@ -2677,7 +2801,7 @@ export class WarehouseInventoryService {
['Pickup Truck Plate', data.plateNumber],
['Driver', data.driverName],
['Truck Type', data.truckType],
- ['Gross Weight (Loaded on Truck)', `${data.grossWeightKg.toLocaleString()} kg`],
+ ['Gross Weight (Loaded on Truck)', `${data.grossWeightKg.toLocaleString()} t`],
['Gate-Out Time', gateOut],
['Clearance Status', 'CLEARED FOR WAREHOUSE EXIT'],
];
@@ -2901,6 +3025,21 @@ export class WarehouseInventoryService {
};
}
+ /** Handover PDF resolved by booking (for the portal, which only has bookingId). */
+ async handoverDocumentForBooking(bookingId: string): Promise<{ filename: string; buffer: Buffer }> {
+ const [inv]: Array<{ id: string }> = await this.dataSource.query(
+ `SELECT id FROM freight.warehouse_inventory
+ WHERE booking_id = $1 AND deleted_at IS NULL
+ ORDER BY updated_at DESC NULLS LAST, created_at DESC
+ LIMIT 1`,
+ [bookingId],
+ );
+ if (!inv) {
+ throw new NotFoundException(`No warehouse inventory found for booking ${bookingId}`);
+ }
+ return this.handoverDocument(inv.id);
+ }
+
async handoverDocument(id: string): Promise<{ filename: string; buffer: Buffer }> {
const [row] = await this.dataSource.query(
`SELECT inv.id,
@@ -3127,7 +3266,22 @@ export class WarehouseInventoryService {
[item.bookingId],
);
} else {
- await this.handover.ensureAtDelivery(item.bookingId, {}, manager);
+ // EDR last-mile: the handover is per delivering truck. Resolve the
+ // vehicle that carried this item's container so each truck gets its own
+ // handover (falls back to a booking-level one when unresolvable).
+ let truckPlate: string | null = null;
+ if (item.containerId) {
+ const [veh]: Array<{ plate: string | null }> = await manager.query(
+ `SELECT COALESCE(v.power_plate_no, v.plate_number) AS plate
+ FROM freight.last_mile_container_allocations lca
+ JOIN freight.vehicles v ON v.id = lca.vehicle_id
+ WHERE lca.container_id = $1 AND lca.vehicle_id IS NOT NULL
+ LIMIT 1`,
+ [item.containerId],
+ );
+ truckPlate = veh?.plate ?? null;
+ }
+ await this.handover.ensureAtDelivery(item.bookingId, { truckPlate }, manager);
}
}
});
@@ -3608,8 +3762,8 @@ export class WarehouseInventoryService {
['Booking Containers', data.bookingContainerSummary],
['Cargo / Goods Description', data.cargoDescription],
['Quantity', data.quantity],
- ['Received Weight', `${data.weight.toLocaleString()} kg`],
- ['Booking Declared Weight', data.bookingDeclaredWeight ? `${data.bookingDeclaredWeight.toLocaleString()} kg` : null],
+ ['Received Weight', `${data.weight.toLocaleString()} t`],
+ ['Booking Declared Weight', data.bookingDeclaredWeight ? `${data.bookingDeclaredWeight.toLocaleString()} t` : null],
['Volume', data.volume == null ? null : data.volume.toLocaleString()],
['Warehouse', data.warehouse],
['Yard', data.yard],
@@ -3731,7 +3885,7 @@ export class WarehouseInventoryService {
`${(data.truckPlateNumber && data.truckWeightKg
? data.truckWeightKg
: data.weight
- ).toLocaleString()} kg`,
+ ).toLocaleString()} t`,
],
['Warehouse', data.warehouse],
['Yard', data.yard],
@@ -3894,8 +4048,8 @@ export class WarehouseInventoryService {
['Booking Containers', data.bookingContainerSummary],
['Cargo / Goods Description', data.cargoDescription],
['Quantity', data.quantity],
- ['Inventory Weight', `${data.weight.toLocaleString()} kg`],
- ['Booking Declared Weight', data.bookingDeclaredWeight ? `${data.bookingDeclaredWeight.toLocaleString()} kg` : null],
+ ['Inventory Weight', `${data.weight.toLocaleString()} t`],
+ ['Booking Declared Weight', data.bookingDeclaredWeight ? `${data.bookingDeclaredWeight.toLocaleString()} t` : null],
['Warehouse', data.warehouse],
['Yard', data.yard],
['Zone', data.zone],
@@ -3968,8 +4122,8 @@ export class WarehouseInventoryService {
1. Goods ${esc(data.cargoDescription || data.containerNumber || data.bookingReference)}
Container ${esc(data.containerNumber)}
Booking Containers ${esc(data.bookingContainerSummary)}
- Inventory Weight ${esc(`${data.weight.toLocaleString()} kg`)}
- Booking Declared Weight ${esc(data.bookingDeclaredWeight ? `${data.bookingDeclaredWeight.toLocaleString()} kg` : null)}
+ Inventory Weight ${esc(`${data.weight.toLocaleString()} t`)}
+ Booking Declared Weight ${esc(data.bookingDeclaredWeight ? `${data.bookingDeclaredWeight.toLocaleString()} t` : null)}
Handover Clause
@@ -4303,9 +4457,9 @@ export class WarehouseInventoryService {
dto.truckType?.trim() ? `Truck Type: ${dto.truckType.trim()}` : null,
dto.containerNumber?.trim() ? `Container Number: ${dto.containerNumber.trim()}` : null,
dto.gateInTime ? `Gate In Time: ${dto.gateInTime}` : null,
- `Tare Weight: ${tareWeight} kg`,
- grossWeight == null ? null : `Gross Weight: ${grossWeight} kg`,
- computedNetWeight == null ? null : `Net Weight: ${computedNetWeight} kg`,
+ `Tare Weight: ${tareWeight} t`,
+ grossWeight == null ? null : `Gross Weight: ${grossWeight} t`,
+ computedNetWeight == null ? null : `Net Weight: ${computedNetWeight} t`,
dto.gateOutTime ? `Gate Out Time: ${dto.gateOutTime}` : null,
];
@@ -4357,7 +4511,7 @@ export class WarehouseInventoryService {
}
private extractExitInspectionNumber(note: string | null | undefined, label: string): number | undefined {
- const value = this.extractExitInspectionLine(note, label)?.replace(/\s*kg$/i, '');
+ const value = this.extractExitInspectionLine(note, label)?.replace(/\s*(kg|t)$/i, '');
if (!value) return undefined;
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : undefined;
@@ -4420,9 +4574,9 @@ export class WarehouseInventoryService {
truck?.driverName ? `Driver: ${truck.driverName}` : null,
truck?.driverPhone ? `Driver Phone: ${truck.driverPhone}` : null,
truck?.driverLicenseNumber ? `Driver License: ${truck.driverLicenseNumber}` : null,
- truck?.entranceTareWeightKg !== undefined ? `Entrance Tare Weight: ${Number(truck.entranceTareWeightKg)} kg` : null,
+ truck?.entranceTareWeightKg !== undefined ? `Entrance Tare Weight: ${Number(truck.entranceTareWeightKg)} t` : null,
truck?.weighingRequired !== undefined ? `Weighing Required: ${truck.weighingRequired ? 'Yes' : 'No'}` : null,
- truck?.exitTareWeightKg !== undefined ? `Exit Tare Weight: ${Number(truck.exitTareWeightKg)} kg` : null,
+ truck?.exitTareWeightKg !== undefined ? `Exit Tare Weight: ${Number(truck.exitTareWeightKg)} t` : null,
truck?.declarationNumber ? `Declaration / Bill of Entry: ${truck.declarationNumber}` : null,
truck?.incoterms ? `Incoterms: ${truck.incoterms}` : null,
truck?.hsCodes ? `HS Codes: ${truck.hsCodes}` : null,
@@ -4430,8 +4584,8 @@ export class WarehouseInventoryService {
truck?.itemDescription ? `Item Description: ${truck.itemDescription}` : null,
truck?.packagingType ? `Packaging Type: ${truck.packagingType}` : null,
truck?.unitCount !== undefined ? `Unit Count: ${Number(truck.unitCount)}` : null,
- truck?.grossWeightKg !== undefined ? `Gross Weight: ${Number(truck.grossWeightKg)} kg` : null,
- truck?.netWeightKg !== undefined ? `Net Weight: ${Number(truck.netWeightKg)} kg` : null,
+ truck?.grossWeightKg !== undefined ? `Gross Weight: ${Number(truck.grossWeightKg)} t` : null,
+ truck?.netWeightKg !== undefined ? `Net Weight: ${Number(truck.netWeightKg)} t` : null,
truck?.volumeDimensions ? `Volume / Dimensions: ${truck.volumeDimensions}` : null,
truck?.conditionAtReceipt ? `Condition at Receipt: ${truck.conditionAtReceipt}` : null,
truck?.damagedRejectedQuantity !== undefined ? `Damaged / Rejected Quantity: ${Number(truck.damagedRejectedQuantity)}` : null,
diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.service.ts
index 9ee1dc398..2508e373a 100644
--- a/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.service.ts
+++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.service.ts
@@ -6,9 +6,11 @@ import {
NotFoundException,
} from "@nestjs/common";
import { OnEvent } from "@nestjs/event-emitter";
-import { Freight } from "@edr/types";
+import { Freight, NotificationAudience, NotificationType } from "@edr/types";
import { DataSource } from "typeorm";
+import { NotificationInboxService } from "../notification-inbox/notification-inbox.service";
+
import {
BillingService,
InvoiceEventPayload,
@@ -135,6 +137,7 @@ export class WarehouseInvoiceService {
private readonly invoiceDocuments: InvoiceDocumentService,
private readonly feeService: WarehouseFeeService,
private readonly notifications: NotificationsService,
+ private readonly inbox: NotificationInboxService,
) { }
// ── Generation ───────────────────────────────────────────────────────────
@@ -968,6 +971,25 @@ export class WarehouseInvoiceService {
message,
`warehouse fee invoice ${invoice.invoiceNumber}`,
);
+
+ // In-app deep-link to pay the fee from the booking.
+ if (invoice.customerId && invoice.bookingId) {
+ try {
+ await this.inbox.notify({
+ recipients: { companyId: invoice.customerId },
+ audience: NotificationAudience.PORTAL,
+ type: NotificationType.INVOICE_ISSUED,
+ title: "Warehouse fee due",
+ body:
+ `Warehouse ${invoice.invoiceType.replace(/_/g, " ").toLowerCase()} fee ${invoice.invoiceNumber} is due — ` +
+ `${Number(invoice.totalAmount).toLocaleString()} ${invoice.currency}. Pay from the portal before cargo pickup.`,
+ link: `/bookings/${invoice.bookingId}`,
+ data: { bookingId: invoice.bookingId, invoiceNumber: invoice.invoiceNumber },
+ });
+ } catch (err) {
+ this.logger.warn(`In-app warehouse fee notify failed: ${(err as Error).message}`);
+ }
+ }
}
private async notifyWarehouseFeePayment(
diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouses.module.ts b/apps/edr-freight-api/src/modules/warehouses/warehouses.module.ts
index 5f0ce5749..4011bc14f 100644
--- a/apps/edr-freight-api/src/modules/warehouses/warehouses.module.ts
+++ b/apps/edr-freight-api/src/modules/warehouses/warehouses.module.ts
@@ -9,6 +9,7 @@ import { FilesModule } from '../files/files.module';
import { InterchangeDocumentsModule } from '../interchange-documents/interchange-documents.module';
import { LastMileModule } from '../last-mile/last-mile.module';
import { NotificationsModule } from '../notifications/notifications.module';
+import { NotificationInboxModule } from '../notification-inbox/notification-inbox.module';
import { SignaturesModule } from '../signatures/signatures.module';
import { WarehouseActivityLog } from './entities/warehouse-activity-log.entity';
import { WarehouseAllocationRule } from './entities/warehouse-allocation-rule.entity';
@@ -75,6 +76,7 @@ import { WarehousesService } from './warehouses.service';
InterchangeDocumentsModule,
forwardRef(() => LastMileModule),
NotificationsModule,
+ NotificationInboxModule,
SignaturesModule,
ExchangeModule.forRootAsync({
inject: [ConfigService],
diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouses.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouses.service.ts
index f92401dfc..140d9f6b4 100644
--- a/apps/edr-freight-api/src/modules/warehouses/warehouses.service.ts
+++ b/apps/edr-freight-api/src/modules/warehouses/warehouses.service.ts
@@ -64,8 +64,8 @@ export class WarehousesService {
currentWeight: 0,
currentContainers: 0,
currentVolume: 0,
- status: 'ACTIVE',
- isActive: true,
+ status: dto.status ?? 'ACTIVE',
+ isActive: (dto.status ?? 'ACTIVE') === 'ACTIVE',
});
} catch (error) {
this.mapDbError(error);
diff --git a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts
index d4d85e84c..8d70bc61c 100644
--- a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts
+++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts
@@ -204,6 +204,7 @@ export const FLEET_ROAD_PERMISSIONS: FreightPermissionSeed[] = [
perm('e2b00001-0001-4000-8000-000000000003', 'edr_freight_app:drivers:update', 'Update driver'),
perm('e2b00001-0001-4000-8000-000000000004', 'edr_freight_app:drivers:delete', 'Delete driver'),
perm('e2c00001-0001-4000-8000-000000000001', 'edr_freight_app:tracking:view', 'Track vehicles'),
+ perm('e2c00001-0001-4000-8000-000000000002', 'edr_freight_app:tracking:manage', 'Manage GPS trackers'),
perm('e2d00001-0001-4000-8000-000000000001', 'edr_freight_app:fuel:view', 'View fuel purchases'),
perm('e2d00001-0001-4000-8000-000000000002', 'edr_freight_app:fuel:create', 'Create fuel purchase'),
perm('e2d00001-0001-4000-8000-000000000003', 'edr_freight_app:fuel:update', 'Update fuel purchase'),
@@ -477,6 +478,7 @@ export const FREIGHT_PERMS = {
},
tracking: {
view: 'edr_freight_app:tracking:view',
+ manage: 'edr_freight_app:tracking:manage',
},
fuel: {
view: 'edr_freight_app:fuel:view',
diff --git a/apps/edr-freight-web/backoffice/.env.example b/apps/edr-freight-web/backoffice/.env.example
index cbbdd289f..a5e34a35d 100644
--- a/apps/edr-freight-web/backoffice/.env.example
+++ b/apps/edr-freight-web/backoffice/.env.example
@@ -1,2 +1,6 @@
VITE_API_URL=http://localhost:3001
VITE_BASE_API_URL=http://localhost:3001
+
+# Proactive token refresh cadence (minutes). Must stay well under the 60-min
+# server session window. Default: 10.
+VITE_TOKEN_REFRESH_INTERVAL_MINUTES=10
diff --git a/apps/edr-freight-web/backoffice/src/auth/AuthProvider.tsx b/apps/edr-freight-web/backoffice/src/auth/AuthProvider.tsx
index 66834c9f5..8266d96d7 100644
--- a/apps/edr-freight-web/backoffice/src/auth/AuthProvider.tsx
+++ b/apps/edr-freight-web/backoffice/src/auth/AuthProvider.tsx
@@ -16,6 +16,10 @@ import {
setCookie,
} from "./cookies";
import { applyTokens } from "./http";
+import {
+ startTokenRefreshScheduler,
+ stopTokenRefreshScheduler,
+} from "./refreshScheduler";
import type { AuthTokens, AuthUser } from "./types";
interface LoginPayload {
@@ -99,6 +103,18 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => {
void bootstrap();
}, []);
+ // Keep the server session alive while a user is logged in. Runs after
+ // login, MFA verification, and page-reload bootstrap alike.
+ useEffect(() => {
+ if (!user) {
+ stopTokenRefreshScheduler();
+ return;
+ }
+
+ startTokenRefreshScheduler();
+ return stopTokenRefreshScheduler;
+ }, [user]);
+
const value = useMemo(
() => ({
user,
diff --git a/apps/edr-freight-web/backoffice/src/auth/http.ts b/apps/edr-freight-web/backoffice/src/auth/http.ts
index 5aa78e2d3..2b0cf1021 100644
--- a/apps/edr-freight-web/backoffice/src/auth/http.ts
+++ b/apps/edr-freight-web/backoffice/src/auth/http.ts
@@ -28,6 +28,30 @@ const applyTokens = ({ token, refreshToken }: AuthTokens) => {
setCookie(REFRESH_TOKEN_COOKIE, refreshToken);
};
+/**
+ * Single-flight token refresh: concurrent callers (the 401 interceptor and
+ * the proactive scheduler) share one in-flight request so the refresh token
+ * is only rotated once. Throws if no refresh token is stored or the server
+ * rejects it — callers decide how to end the session.
+ */
+const refreshSessionTokens = async (): Promise => {
+ const refreshToken = getCookie(REFRESH_TOKEN_COOKIE);
+ if (!refreshToken) {
+ throw new Error("missing refresh token");
+ }
+
+ refreshPromise ??= api
+ .post("/auth/refresh-token", { refreshToken })
+ .then((response) => response.data)
+ .finally(() => {
+ refreshPromise = null;
+ });
+
+ const tokens = await refreshPromise;
+ applyTokens(tokens);
+ return tokens;
+};
+
api.interceptors.request.use((config) => {
const token = getCookie(AUTH_TOKEN_COOKIE);
@@ -65,8 +89,7 @@ api.interceptors.response.use(
return Promise.reject(error);
}
- const refreshToken = getCookie(REFRESH_TOKEN_COOKIE);
- if (!refreshToken) {
+ if (!getCookie(REFRESH_TOKEN_COOKIE)) {
clearSessionCookies();
return Promise.reject(error);
}
@@ -74,15 +97,7 @@ api.interceptors.response.use(
originalRequest._retry = true;
try {
- refreshPromise ??= api
- .post("/auth/refresh-token", { refreshToken })
- .then((response) => response.data)
- .finally(() => {
- refreshPromise = null;
- });
-
- const tokens = await refreshPromise;
- applyTokens(tokens);
+ const tokens = await refreshSessionTokens();
originalRequest.headers = {
...originalRequest.headers,
Authorization: `Bearer ${tokens.token}`,
@@ -97,4 +112,4 @@ api.interceptors.response.use(
},
);
-export { api, applyTokens };
+export { api, applyTokens, refreshSessionTokens };
diff --git a/apps/edr-freight-web/backoffice/src/auth/refreshScheduler.ts b/apps/edr-freight-web/backoffice/src/auth/refreshScheduler.ts
new file mode 100644
index 000000000..1d2c14db2
--- /dev/null
+++ b/apps/edr-freight-web/backoffice/src/auth/refreshScheduler.ts
@@ -0,0 +1,82 @@
+import { isAxiosError } from "axios";
+
+import {
+ REFRESH_TOKEN_COOKIE,
+ clearSessionCookies,
+ getCookie,
+} from "./cookies";
+import { refreshSessionTokens } from "./http";
+
+/**
+ * Proactively refreshes the token pair on a fixed cadence so the server-side
+ * session (a sliding 1-hour window, extended only by /auth/refresh-token) is
+ * kept alive while the app is open. The 401 interceptor in http.ts remains
+ * the reactive fallback; both share the same single-flight refresh call.
+ *
+ * The interval MUST stay well under the server session window (60 min).
+ */
+const DEFAULT_INTERVAL_MINUTES = 10;
+
+const getIntervalMs = () => {
+ const minutes = Number(import.meta.env.VITE_TOKEN_REFRESH_INTERVAL_MINUTES);
+ return (
+ (Number.isFinite(minutes) && minutes > 0
+ ? minutes
+ : DEFAULT_INTERVAL_MINUTES) * 60_000
+ );
+};
+
+let timerId: number | null = null;
+let lastRefreshAt = 0;
+
+const refreshNow = async () => {
+ if (!getCookie(REFRESH_TOKEN_COOKIE)) {
+ // Logged out elsewhere; nothing to keep alive.
+ stopTokenRefreshScheduler();
+ return;
+ }
+
+ try {
+ await refreshSessionTokens();
+ lastRefreshAt = Date.now();
+ } catch (error) {
+ // Network hiccups are retried on the next tick; only an explicit server
+ // rejection means the session is dead.
+ if (isAxiosError(error) && error.response) {
+ stopTokenRefreshScheduler();
+ clearSessionCookies();
+ window.location.replace("/auth");
+ }
+ }
+};
+
+/**
+ * Browsers freeze timers in background tabs — a tab waking up past its
+ * refresh deadline refreshes immediately instead of waiting a full interval.
+ */
+const onVisibilityChange = () => {
+ if (document.visibilityState !== "visible") return;
+ if (Date.now() - lastRefreshAt >= getIntervalMs()) {
+ void refreshNow();
+ }
+};
+
+export const startTokenRefreshScheduler = () => {
+ stopTokenRefreshScheduler();
+
+ // Token age is unknown here (fresh login vs. hours-old page reload), so
+ // refresh right away to extend the session window from "now".
+ lastRefreshAt = 0;
+ void refreshNow();
+
+ timerId = window.setInterval(() => void refreshNow(), getIntervalMs());
+ document.addEventListener("visibilitychange", onVisibilityChange);
+};
+
+export const stopTokenRefreshScheduler = () => {
+ if (timerId !== null) {
+ window.clearInterval(timerId);
+ timerId = null;
+ }
+ document.removeEventListener("visibilitychange", onVisibilityChange);
+};
diff --git a/apps/edr-freight-web/backoffice/src/components/customers/ChangeRequestReview.tsx b/apps/edr-freight-web/backoffice/src/components/customers/ChangeRequestReview.tsx
new file mode 100644
index 000000000..924d62436
--- /dev/null
+++ b/apps/edr-freight-web/backoffice/src/components/customers/ChangeRequestReview.tsx
@@ -0,0 +1,379 @@
+import {
+ Alert,
+ Anchor,
+ Badge,
+ Box,
+ Button,
+ Card,
+ Group,
+ Modal,
+ SimpleGrid,
+ Stack,
+ Text,
+ Textarea,
+} from "@mantine/core";
+import { useQuery, useMutation } from "@tanstack/react-query";
+import {
+ AlertTriangle,
+ ClipboardCheck,
+ Clock,
+ FilePlus2,
+ FileX2,
+} from "lucide-react";
+import { useState } from "react";
+import { useFileViewer } from "@edr/ui-common";
+
+import { fileViewUrl } from "@/constants/apiConfig";
+import { api } from "@/services/api";
+import type { Company, CompanyChangeRequest } from "@/types/customer";
+import { formatDate, humanize } from "./format";
+
+/** Friendly labels for the proposed-change snapshot keys (UpdateProfileDto). */
+const FIELD_LABELS: Record = {
+ companyName: "Company name",
+ companyEmail: "Company email",
+ companyPhone: "Company phone",
+ companyLocation: "Location",
+ companyAddress: "Address",
+ tin: "TIN",
+ vatNumber: "VAT number",
+ fanNumber: "FAN number",
+ nationality: "Nationality",
+ licenceNumber: "Licence number",
+ contactPersonName: "Contact person",
+ contactPersonPosition: "Contact position",
+ contactPersonEmail: "Contact email",
+ contactPersonPhone: "Contact phone",
+ generalManagerName: "General manager",
+ generalManagerEmail: "GM email",
+ generalManagerPhone: "GM phone",
+ poaName: "PoA name",
+ poaPhone: "PoA phone",
+ poaEmail: "PoA email",
+ poaLocation: "PoA location",
+ poaAddress: "PoA address",
+ region: "Region",
+ zone: "Zone",
+ woreda: "Woreda",
+ kebele: "Kebele",
+ houseNo: "House no.",
+};
+
+/** Best-effort current value on the live company for a proposed field key. */
+function currentValue(company: Company, key: string): string {
+ const c = company as unknown as Record;
+ const attrs = (company.attributes ?? {}) as Record;
+ const map: Record = {
+ companyName: c.name,
+ companyEmail: c.email,
+ companyPhone: c.phone,
+ companyLocation: c.country,
+ companyAddress: c.address,
+ tin: c.tin,
+ vatNumber: c.vatNumber,
+ fanNumber: c.fanNumber,
+ nationality: c.nationality,
+ contactPersonName: c.contactPersonName ?? attrs.contactPersonName,
+ contactPersonPhone: c.contactPersonPhone ?? attrs.contactPersonPhone,
+ generalManagerName: c.generalManagerName ?? attrs.generalManagerName,
+ generalManagerEmail: c.generalManagerEmail ?? attrs.generalManagerEmail,
+ generalManagerPhone: c.generalManagerPhone ?? attrs.generalManagerPhone,
+ };
+ const v = key in map ? map[key] : (c[key] ?? attrs[key]);
+ return v === null || v === undefined || v === "" ? "—" : String(v);
+}
+
+function DiffRow({
+ label,
+ from,
+ to,
+}: {
+ label: string;
+ from: string;
+ to: string;
+}) {
+ const changed = from !== to;
+ return (
+
+
+ {label}
+
+
+
+ {from}
+
+ {changed && (
+ <>
+
+ →
+
+
+ {to}
+
+ >
+ )}
+
+
+ );
+}
+
+/**
+ * Backoffice review surface for a customer's staged profile edits. Shows the
+ * pending change request as a proposed-vs-current diff with Approve / Reject
+ * (with note) actions, plus a short history of past decisions.
+ */
+export function ChangeRequestReview({ company }: { company: Company }) {
+ const query = useQuery(
+ api.customers.changeRequests.queryOptions({ input: { id: company.id } }),
+ );
+ const approve = useMutation(
+ api.customers.approveChangeRequest.mutationOptions(),
+ );
+ const reject = useMutation(
+ api.customers.rejectChangeRequest.mutationOptions(),
+ );
+
+ const { view, viewer } = useFileViewer();
+ const [rejectId, setRejectId] = useState(null);
+ const [note, setNote] = useState("");
+
+ const requests = query.data ?? [];
+ const pending = requests.find((r) => r.status === "pending");
+ const history = requests.filter((r) => r.status !== "pending").slice(0, 5);
+
+ if (!pending && history.length === 0) return null;
+
+ const proposedKeys = pending
+ ? Object.keys(pending.snapshot ?? {})
+ : ([] as string[]);
+ const docCount = pending?.documentFileIds?.length ?? 0;
+ const licenseChanges = pending?.licenseChanges ?? [];
+
+ const confirmReject = () => {
+ if (!rejectId) return;
+ reject.mutate(
+ { id: rejectId, note: note.trim() },
+ {
+ onSuccess: () => {
+ setRejectId(null);
+ setNote("");
+ },
+ },
+ );
+ };
+
+ return (
+ <>
+ {pending && (
+
+
+
+
+
+
+ Profile changes awaiting review
+
+
+ Pending
+
+
+
+ Submitted {formatDate(pending.submittedAt ?? pending.createdAt)}
+
+
+
+ {proposedKeys.length > 0 ? (
+
+ {proposedKeys.map((key) => (
+
+ ))}
+
+ ) : (
+
+ No field changes — document uploads only.
+
+ )}
+
+ {docCount > 0 && (
+
+ {docCount} document{docCount === 1 ? "" : "s"} uploaded with this
+ request — review them in the Documents tab.
+
+ )}
+
+ {licenseChanges.length > 0 && (
+
+
+ Business license changes
+
+ {licenseChanges.map((c, i) => (
+
+ {c.op === "add" ? (
+
+ ) : (
+
+ )}
+
+ {c.op === "add" ? "Add" : "Remove"}
+
+
+ view({
+ name: c.fileName ?? "License document",
+ url: fileViewUrl(c.fileId),
+ })
+ }
+ style={{
+ textDecoration:
+ c.op === "remove" ? "line-through" : undefined,
+ }}
+ >
+ {c.fileName ?? "License document"}
+
+
+ ))}
+
+ )}
+
+
+ {
+ setRejectId(pending.id);
+ setNote("");
+ }}
+ >
+ Reject
+
+ approve.mutate({ id: pending.id })}
+ >
+ Approve changes
+
+
+
+
+ )}
+
+ {history.length > 0 && (
+
+
+
+ Review history
+
+ {history.map((r: CompanyChangeRequest) => (
+
+
+ {r.status}
+
+
+
+ {formatDate(r.reviewedAt ?? r.updatedAt)}
+
+ {r.note && (
+
+ Note: {r.note}
+
+ )}
+
+
+ ))}
+
+
+ )}
+
+ setRejectId(null)}
+ title="Reject changes"
+ centered
+ radius="lg"
+ >
+
+ }>
+ The customer will see this note and can amend and resubmit.
+
+
+
+
+ {viewer}
+ >
+ );
+}
+
+/** Compact "N changes pending" pill for the customer list/detail header. */
+export function ChangeRequestPendingBadge({ companyId }: { companyId: string }) {
+ const query = useQuery(
+ api.customers.changeRequests.queryOptions({ input: { id: companyId } }),
+ );
+ const pending = (query.data ?? []).some((r) => r.status === "pending");
+ if (!pending) return null;
+ return (
+ }
+ >
+ Changes pending review
+
+ );
+}
diff --git a/apps/edr-freight-web/backoffice/src/components/customers/badges.tsx b/apps/edr-freight-web/backoffice/src/components/customers/badges.tsx
index 5bd6a7527..76555a014 100644
--- a/apps/edr-freight-web/backoffice/src/components/customers/badges.tsx
+++ b/apps/edr-freight-web/backoffice/src/components/customers/badges.tsx
@@ -1,6 +1,16 @@
import type { Freight } from "@edr/types";
-import { Badge, Button, Group, Tooltip } from "@mantine/core";
+import {
+ Badge,
+ Button,
+ Group,
+ Modal,
+ Stack,
+ Text,
+ Textarea,
+ Tooltip,
+} from "@mantine/core";
import { useMutation } from "@tanstack/react-query";
+import { useState } from "react";
import { api } from "@/services/api";
import type {
@@ -25,6 +35,7 @@ const badgeStyle = {
const STATUS_COLOR: Record = {
active: "edr-green",
pending: "yellow",
+ rejected: "red",
suspended: "orange",
blacklisted: "red",
};
@@ -266,7 +277,9 @@ export function InvoiceStatusBadge({
/**
* Inline approval action buttons for a profile row.
- * Transitions: pending → approve/reject | active → suspend | suspended → reactivate/blacklist | blacklisted → reinstate
+ * Transitions: pending → approve / reject-with-note | rejected → approve (override) |
+ * active → suspend | suspended → reactivate/blacklist | blacklisted → reinstate.
+ * Rejecting captures a note the customer sees so they can fix and reapply.
*/
export function ProfileApprovalActions({
profileId,
@@ -278,33 +291,102 @@ export function ProfileApprovalActions({
const { mutate, isPending } = useMutation(
api.customers.setProfileStatus.mutationOptions(),
);
+ const [rejectOpen, setRejectOpen] = useState(false);
+ const [note, setNote] = useState("");
const act = (next: ProfileStatus) => mutate({ profileId, status: next });
+ const confirmReject = () => {
+ mutate(
+ { profileId, status: "rejected", note: note.trim() },
+ { onSuccess: () => setRejectOpen(false) },
+ );
+ };
+
+ const rejectModal = (
+ setRejectOpen(false)}
+ title="Reject profile"
+ centered
+ radius="lg"
+ >
+
+
+ Tell the customer what needs fixing. They'll see this note and can
+ amend and resubmit the role for approval.
+
+
+
+ );
+
if (status === "pending") {
return (
-
- act("active")}
- >
- Approve
-
- act("blacklisted")}
- >
- Reject
-
-
+ <>
+ {rejectModal}
+
+ act("active")}
+ >
+ Approve
+
+ setRejectOpen(true)}
+ >
+ Reject
+
+
+ >
+ );
+ }
+
+ if (status === "rejected") {
+ return (
+ act("active")}
+ >
+ Approve
+
);
}
diff --git a/apps/edr-freight-web/backoffice/src/components/customers/index.ts b/apps/edr-freight-web/backoffice/src/components/customers/index.ts
index 620b5ec35..2a87e3b0c 100644
--- a/apps/edr-freight-web/backoffice/src/components/customers/index.ts
+++ b/apps/edr-freight-web/backoffice/src/components/customers/index.ts
@@ -9,5 +9,9 @@ export {
ProfileStatusBadge,
ProfileTypeBadge,
} from "./badges";
+export {
+ ChangeRequestReview,
+ ChangeRequestPendingBadge,
+} from "./ChangeRequestReview";
export { formatBytes, formatDate, formatMoney, humanize } from "./format";
export { TableCard, type TableCardProps } from "./TableCard";
diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/ContainerItemsModal.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/ContainerItemsModal.tsx
index 168bfd744..57be67d68 100644
--- a/apps/edr-freight-web/backoffice/src/components/warehouses/ContainerItemsModal.tsx
+++ b/apps/edr-freight-web/backoffice/src/components/warehouses/ContainerItemsModal.tsx
@@ -11,6 +11,7 @@ import {
Table,
Tabs,
Text,
+ Tooltip,
} from '@mantine/core';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { FileText } from 'lucide-react';
@@ -22,7 +23,7 @@ import {
type ContainerItem,
type ContainerItemStage,
} from '@/services/warehouse.service';
-import { extractErrorMessage } from './options';
+import { extractDownloadErrorMessage, extractErrorMessage } from './options';
import { openPdfBlob } from './pdf';
interface ContainerItemsModalProps {
@@ -36,6 +37,7 @@ const STAGE_TABS: Array<{ value: string; label: string }> = [
{ value: 'ALL', label: 'All' },
{ value: 'RECEIVED', label: 'Received' },
{ value: 'GRN', label: "GRN'd" },
+ { value: 'ASSIGNED', label: 'Assigned' },
{ value: 'LOADED', label: 'Loaded' },
{ value: 'LEFT', label: 'Left' },
{ value: 'DELIVERED', label: 'Delivered' },
@@ -45,13 +47,15 @@ const STAGE_COLOR: Record = {
PENDING: 'gray',
RECEIVED: 'blue',
GRN: 'teal',
+ ASSIGNED: 'indigo',
LOADED: 'grape',
LEFT: 'orange',
DELIVERED: 'green',
};
-/** Loadable = not yet on a truck (before LOADED). */
-const isLoadable = (i: ContainerItem) => i.stage === 'PENDING' || i.stage === 'RECEIVED' || i.stage === 'GRN';
+/** Loadable = not yet loaded (PENDING/RECEIVED/GRN, or customer-ASSIGNED awaiting load). */
+const isLoadable = (i: ContainerItem) =>
+ i.stage === 'PENDING' || i.stage === 'RECEIVED' || i.stage === 'GRN' || i.stage === 'ASSIGNED';
export function ContainerItemsModal({ opened, onClose, bookingId, bookingReference }: ContainerItemsModalProps) {
const { toast } = useToast();
@@ -76,8 +80,13 @@ export function ContainerItemsModal({ opened, onClose, bookingId, bookingReferen
() => (tab === 'ALL' ? items : items.filter((i) => i.stage === tab)),
[items, tab],
);
+ // Only arrived, not-yet-departed trucks can be loaded.
const truckOptions = trucks
- .filter((t) => !(t as { departedAt?: string }).departedAt)
+ .filter(
+ (t) =>
+ Boolean((t as { arrivedAt?: string }).arrivedAt) &&
+ !(t as { departedAt?: string }).departedAt,
+ )
.map((t) => ({ value: t.id, label: `${t.plateNumber} · ${t.driverName}` }));
const loadMutation = useMutation({
@@ -90,12 +99,29 @@ export function ContainerItemsModal({ opened, onClose, bookingId, bookingReferen
onError: (e) => toast({ variant: 'destructive', title: 'Load failed', description: extractErrorMessage(e) }),
});
+ const requestSign = async () => {
+ try {
+ const res = await warehouseService.requestHandoverSignature(bookingId as string);
+ queryClient.invalidateQueries({ queryKey: itemsKey });
+ if (res.alreadySigned) {
+ toast({ title: 'Handover already signed', description: 'You can generate the exit paper now.' });
+ } else {
+ toast({
+ title: 'Handover not signed',
+ description: `Signature request sent to the customer${res.reference ? ` (${res.reference})` : ''}.`,
+ });
+ }
+ } catch (e) {
+ toast({ variant: 'destructive', title: 'Could not request signature', description: extractErrorMessage(e) });
+ }
+ };
+
const openExitPaper = async (assignmentId: string, plate: string) => {
try {
const res = await warehouseService.downloadTruckExitPaper(assignmentId);
openPdfBlob(res.data, `exit-${plate}.pdf`);
} catch (e) {
- toast({ variant: 'destructive', title: 'Exit paper not ready', description: extractErrorMessage(e) });
+ toast({ variant: 'destructive', title: 'Exit paper not ready', description: await extractDownloadErrorMessage(e) });
}
};
@@ -163,16 +189,28 @@ export function ContainerItemsModal({ opened, onClose, bookingId, bookingReferen
{i.contractId ? Contract : '—'}
{i.hasLastMile ? EDR : Self-haul }
- {i.truckAssignmentId && (
- }
- onClick={() => openExitPaper(i.truckAssignmentId as string, i.truckPlate ?? '')}
+ {i.loaded && i.truckAssignmentId && (
+
- Exit Paper
-
+ }
+ onClick={() =>
+ i.handoverSigned
+ ? openExitPaper(i.truckAssignmentId as string, i.truckPlate ?? '')
+ : requestSign()
+ }
+ >
+ Exit Paper
+
+
)}
diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/CreateWarehouseModal.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/CreateWarehouseModal.tsx
index 6494906b4..8c8560442 100644
--- a/apps/edr-freight-web/backoffice/src/components/warehouses/CreateWarehouseModal.tsx
+++ b/apps/edr-freight-web/backoffice/src/components/warehouses/CreateWarehouseModal.tsx
@@ -102,7 +102,7 @@ export function CreateWarehouseModal({ opened, onClose, warehouse }: CreateWareh
await updateMutation.mutateAsync({ id: warehouse.id, payload: { ...payload, status: form.status } });
toast({ title: 'Warehouse updated' });
} else {
- await createMutation.mutateAsync(payload);
+ await createMutation.mutateAsync({ ...payload, status: form.status });
toast({ title: 'Warehouse created' });
}
onClose();
@@ -149,15 +149,13 @@ export function CreateWarehouseModal({ opened, onClose, warehouse }: CreateWareh
onChange={(value) => setForm((f) => ({ ...f, type: (value as WarehouseType) ?? 'OPEN_WAREHOUSE' }))}
allowDeselect={false}
/>
- {isEdit && (
- setForm((f) => ({ ...f, status: (value as 'ACTIVE' | 'INACTIVE') ?? 'ACTIVE' }))}
- allowDeselect={false}
- />
- )}
+ setForm((f) => ({ ...f, status: (value as 'ACTIVE' | 'INACTIVE') ?? 'ACTIVE' }))}
+ allowDeselect={false}
+ />
setExpectedWeight(v === '' ? '' : Number(v))}
/>
setActualWeight(v === '' ? '' : Number(v))}
diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/InventoryDetailModal.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/InventoryDetailModal.tsx
index c888253f1..e76baa6f0 100644
--- a/apps/edr-freight-web/backoffice/src/components/warehouses/InventoryDetailModal.tsx
+++ b/apps/edr-freight-web/backoffice/src/components/warehouses/InventoryDetailModal.tsx
@@ -78,7 +78,7 @@ export function InventoryDetailModal({ opened, onClose, item }: InventoryDetailM
-
+
diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/InventoryInquiryDetailModal.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/InventoryInquiryDetailModal.tsx
index 479249894..906ba4b47 100644
--- a/apps/edr-freight-web/backoffice/src/components/warehouses/InventoryInquiryDetailModal.tsx
+++ b/apps/edr-freight-web/backoffice/src/components/warehouses/InventoryInquiryDetailModal.tsx
@@ -67,7 +67,7 @@ export function InventoryInquiryDetailModal({ opened, onClose, result }: Invento
-
+
diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/InventoryWorkbench.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/InventoryWorkbench.tsx
index f37db855d..b8bb43086 100644
--- a/apps/edr-freight-web/backoffice/src/components/warehouses/InventoryWorkbench.tsx
+++ b/apps/edr-freight-web/backoffice/src/components/warehouses/InventoryWorkbench.tsx
@@ -17,7 +17,6 @@ import { InventoryHistoryModal } from './InventoryHistoryModal';
import { LoadInventoryModal } from './LoadInventoryModal';
import { MoveInventoryModal } from './MoveInventoryModal';
import { ReleaseOrderModal } from './ReleaseOrderModal';
-import { ReserveInventoryModal } from './ReserveInventoryModal';
import { WarehouseInventoryTable } from './WarehouseInventoryTable';
import { extractErrorMessage } from './options';
import { openPdfBlob } from './pdf';
@@ -29,12 +28,11 @@ interface InventoryWorkbenchProps {
onLastMile?: (item: WarehouseInventoryItem) => void;
}
-/** Inventory table + all lifecycle actions (advance / move / reserve / history). */
+/** Inventory table + all lifecycle actions (advance / move / history). */
export function InventoryWorkbench({ items, isLoading, onLastMile }: InventoryWorkbenchProps) {
const { toast } = useToast();
const [busyId, setBusyId] = useState(null);
const [moveItem, setMoveItem] = useState(null);
- const [reserveItem, setReserveItem] = useState(null);
const [loadItem, setLoadItem] = useState(null);
const [historyItem, setHistoryItem] = useState(null);
const [viewItem, setViewItem] = useState(null);
@@ -171,7 +169,7 @@ export function InventoryWorkbench({ items, isLoading, onLastMile }: InventoryWo
const storeInventory = async (item: WarehouseInventoryItem) => {
setBusyId(item.id);
try {
- const stored = await storeMutation.mutateAsync(item.id);
+ const stored = await storeMutation.mutateAsync({ id: item.id });
toast({
title: 'Inventory stored',
description: [stored.warehouse?.code, stored.yard?.code, stored.zone?.code].filter(Boolean).join(' / '),
@@ -187,9 +185,6 @@ export function InventoryWorkbench({ items, isLoading, onLastMile }: InventoryWo
switch (action) {
case 'store':
return storeInventory(item);
- case 'reserve':
- setReserveItem(item);
- return;
case 'ready-for-loading':
return runDirect(item, () => readyMutation.mutateAsync(item.id), 'Ready for loading');
case 'load':
@@ -258,11 +253,6 @@ export function InventoryWorkbench({ items, isLoading, onLastMile }: InventoryWo
setMoveItem(null)} item={moveItem} />
- setReserveItem(null)}
- item={reserveItem}
- />
setLoadItem(null)} item={loadItem} />
= {
LOADED: 'green',
};
-const weight = (w: number | null) => (w == null ? '—' : `${Number(w).toLocaleString()} kg`);
+const weight = (w: number | null) => (w == null ? '—' : `${Number(w).toLocaleString()} t`);
interface BookingGroup {
bookingId: string | null;
diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx
index 3e3877236..3b4be03db 100644
--- a/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx
+++ b/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx
@@ -7,6 +7,7 @@ import {
Checkbox,
Group,
Loader,
+ Menu,
Modal,
NumberInput,
ScrollArea,
@@ -20,6 +21,7 @@ import {
Tooltip,
} from '@mantine/core';
import {
+ ArrowRightLeft,
ChevronDown,
ChevronRight,
ClipboardCheck,
@@ -27,6 +29,8 @@ import {
FileText,
History,
Info,
+ MapPin,
+ MoreHorizontal,
PackageCheck,
PackageOpen,
PackageSearch,
@@ -61,7 +65,6 @@ import type {
} from '@/types/warehouse';
import { BookingSelect } from './BookingSelect';
import { DeliverInventoryModal } from './DeliverInventoryModal';
-import { TruckDispatchModal } from './TruckDispatchModal';
import { ContainerItemsModal } from './ContainerItemsModal';
import { FeePreviewModal } from './FeePreviewModal';
import { InspectionReportModal } from './InspectionReportModal';
@@ -69,7 +72,9 @@ import { InventoryDetailModal } from './InventoryDetailModal';
import { InventoryHistoryModal } from './InventoryHistoryModal';
import { InventoryWorkbench } from './InventoryWorkbench';
import { InventoryInquiryDetailModal } from './InventoryInquiryDetailModal';
+import { MoveInventoryModal } from './MoveInventoryModal';
import { ReleaseOrderModal } from './ReleaseOrderModal';
+import { StoreInventoryModal } from './StoreInventoryModal';
import { WarehouseInquiryTable } from './WarehouseInquiryTable';
import { extractErrorMessage, formatDate, formatNumber, inventoryStatusOptions } from './options';
import { openPdfBlob } from './pdf';
@@ -520,14 +525,14 @@ function TruckEntranceFields({
{value.weighingRequired && (
onChange({ ...value, grossWeightKg: v === '' ? '' : Number(v) })}
/>
onChange({ ...value, netWeightKg: v === '' ? '' : Number(v) })}
@@ -2182,7 +2187,6 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
const inspectMutation = useMutation(
api.warehouses.bulkMarkInspected.mutationOptions(),
);
- const storeMutation = useMutation(api.warehouses.store.mutationOptions());
const readyMutation = useMutation(api.warehouses.markReadyForPickup.mutationOptions());
const [selected, setSelected] = useState>(new Set());
const [inspectId, setInspectId] = useState(null);
@@ -2192,8 +2196,9 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
const [feeItem, setFeeItem] = useState(null);
const [releaseItem, setReleaseItem] = useState(null);
const [deliverItem, setDeliverItem] = useState(null);
- const [loadTruckItem, setLoadTruckItem] = useState(null);
const [containerItemsItem, setContainerItemsItem] = useState(null);
+ const [storeItem, setStoreItem] = useState(null);
+ const [moveItem, setMoveItem] = useState(null);
const allSelected = rows.length > 0 && selected.size === rows.length;
const someSelected = selected.size > 0 && !allSelected;
@@ -2413,49 +2418,16 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
- {r.currentStatus === 'UNLOADED' && (
+ {/* Primary stage action stays visible; the rest live under the kebab. */}
+ {r.currentStatus === 'READY_FOR_PICKUP' && !r.releaseDate && r.hasAssignedTruck && (
runRowAction(r, 'Inventory stored', () => storeMutation.mutateAsync(r.id))}
+ color="yellow"
+ leftSection={ }
+ onClick={() => setReleaseItem(toInventoryItem(r))}
>
- Store
-
- )}
- {['UNLOADED', 'STORED'].includes(r.currentStatus) && r.inspectionStatus === 'PASSED' && (
- runRowAction(r, 'Ready for pickup', () => readyMutation.mutateAsync(r.id))}
- >
- Ready Pickup
-
- )}
- {r.currentStatus === 'READY_FOR_PICKUP' && !r.releaseDate && (
- <>
- setReleaseItem(toInventoryItem(r))}
- >
- {r.releaseOrderReference ? 'Truck Leaving' : 'Truck Arrival'}
-
- >
- )}
- {r.currentStatus === 'READY_FOR_PICKUP' && (
- setLoadTruckItem(toInventoryItem(r))}
- >
- Truck_dispatch
+ {r.releaseOrderReference ? 'Truck Leaving' : 'Truck Arrival'}
)}
{r.currentStatus === 'READY_FOR_PICKUP' && r.releaseDate && (
@@ -2470,40 +2442,64 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
Exit Paper
)}
- {r.currentStatus === 'READY_FOR_PICKUP' && r.releaseDate && (
- setDeliverItem(toInventoryItem(r))}
- >
- Deliver
-
- )}
- {r.inspectionStatus === 'PASSED' && (
- }
- onClick={() => openHandoverDocument(r)}
- >
- {r.handoverDocumentReference ? 'View Handover' : 'Handover'}
-
- )}
- setInspectId(r.id)}>
- Inspect / Report
-
-
- setFeeItem(toInventoryItem(r))}>
-
-
-
-
- setHistoryItem(toInventoryItem(r))}>
-
-
-
+
+
+
+
+
+
+
+ {r.currentStatus === 'UNLOADED' && (
+ } onClick={() => setStoreItem(toInventoryItem(r))}>
+ Store…
+
+ )}
+ {r.currentStatus !== 'UNLOADED' && (
+ } onClick={() => setMoveItem(toInventoryItem(r))}>
+ Move…
+
+ )}
+ {['UNLOADED', 'STORED'].includes(r.currentStatus) && r.inspectionStatus === 'PASSED' && (
+ runRowAction(r, 'Ready for pickup', () => readyMutation.mutateAsync(r.id))}>
+ Ready for pickup
+
+ )}
+ {r.currentStatus === 'READY_FOR_PICKUP' && !r.releaseDate && (
+ }
+ disabled={!r.hasAssignedTruck}
+ onClick={() => setReleaseItem(toInventoryItem(r))}
+ >
+ {r.hasAssignedTruck
+ ? r.releaseOrderReference
+ ? 'Truck leaving'
+ : 'Truck arrival'
+ : 'Truck arrival — assign a truck first'}
+
+ )}
+ {r.currentStatus === 'READY_FOR_PICKUP' && r.releaseDate && (
+ } onClick={() => openReleaseDocument(r)}>
+ Exit paper
+
+ )}
+ {r.currentStatus === 'READY_FOR_PICKUP' && r.releaseDate && (
+ setDeliverItem(toInventoryItem(r))}>Deliver
+ )}
+ {r.inspectionStatus === 'PASSED' && (
+ } onClick={() => openHandoverDocument(r)}>
+ {r.handoverDocumentReference ? 'View handover' : 'Handover'}
+
+ )}
+ setInspectId(r.id)}>Inspect / report
+
+ } onClick={() => setFeeItem(toInventoryItem(r))}>
+ Storage / fee preview
+
+ } onClick={() => setHistoryItem(toInventoryItem(r))}>
+ History
+
+
+
@@ -2526,13 +2522,9 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
inventoryId={feeItem?.id ?? null}
/>
setReleaseItem(null)} item={releaseItem} />
+ setStoreItem(null)} item={storeItem} />
+ setMoveItem(null)} item={moveItem} />
setDeliverItem(null)} item={deliverItem} />
- setLoadTruckItem(null)}
- bookingId={loadTruckItem?.booking?.id ?? null}
- bookingReference={loadTruckItem?.booking?.reference ?? null}
- />
setContainerItemsItem(null)}
diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/ReleaseOrderModal.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/ReleaseOrderModal.tsx
index 0471d51c6..5cca0d3b2 100644
--- a/apps/edr-freight-web/backoffice/src/components/warehouses/ReleaseOrderModal.tsx
+++ b/apps/edr-freight-web/backoffice/src/components/warehouses/ReleaseOrderModal.tsx
@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react';
-import { Alert, Button, Group, Modal, NumberInput, Select, SimpleGrid, Stack, Text, TextInput } from '@mantine/core';
+import { Alert, Button, Group, Modal, MultiSelect, NumberInput, Select, SimpleGrid, Stack, Text, TextInput } from '@mantine/core';
import { Info, Scale } from 'lucide-react';
import { useMutation, useQuery } from '@tanstack/react-query';
@@ -28,29 +28,6 @@ export interface ReleaseOrderTruckPrefill {
containerNumber?: string | null;
}
-const REGISTERED_FIRST_LAST_MILE_TRUCKS = [
- ['03-ET A45843', '43495'], ['03-ET A45866', '43470'], ['03-ET A45853', '43508'], ['03-ET A45849', '43414'],
- ['03-ET A45845', '43492'], ['03-ET A45820', '43487'], ['03-ET A45842', '43478'], ['03-ET A45841', '43515'],
- ['03-ET A45832', '43504'], ['03-ET A45856', '43510'], ['03-ET A45865', '43490'], ['03-ET A45855', '43485'],
- ['03-ET A45840', '43499'], ['03-ET A45867', '43493'], ['03-ET A45833', '43466'], ['03-ET A45858', '43496'],
- ['03-ET A45819', '43474'], ['03-ET A45834', '43502'], ['03-ET A45868', '43469'], ['03-ET A45831', '43488'],
- ['03-ET A45828', '43479'], ['03-ET A45850', '43505'], ['03-ET A45823', '43480'], ['03-ET A45838', '43472'],
- ['03-ET A45854', '43500'], ['03-ET A45839', '43486'], ['03-ET A45861', '43513'], ['03-ET A45830', '43501'],
- ['03-ET A45826', '43498'], ['03-ET A45836', '43467'], ['03-ET A45822', '43512'], ['03-ET A45821', '43210'],
- ['03-ET A45837', '43475'], ['03-ET A45860', '43497'], ['03-ET A45863', '43477'], ['03-ET A45825', '43483'],
- ['03-ET A45829', '43473'], ['03-ET A45824', '43491'], ['03-ET A45857', '43481'], ['03-ET A45851', '43509'],
- ['03-ET A45827', '43468'], ['03-ET A45859', '43887'], ['03-ET A45846', '43471'], ['03-ET A45847', '43511'],
- ['03-ET A45852', '43484'], ['03-ET A45844', '43476'], ['03-ET A45835', '43482'], ['03-ET A45864', '43503'],
- ['03-ET A45848', '43494'], ['03-ET A45862', '43465'], ['03-ET A39105', '41218'], ['03-ET A39098', '41220'],
- ['03-ET A29900', '41865'], ['03-ET A39097', '41226'], ['03-ET A39104', '41225'], ['03-ET A39103', '41223'],
- ['03-ET A39106', '41221'], ['03-ET A39107', '41215'], ['03-ET A39094', '41222'], ['03-ET A39099', '41216'],
- ['03-ET A39092', '41224'], ['03-ET A31801', '41214'],
-].map(([powerPlate, trailerPlate], index) => ({
- value: powerPlate,
- label: `${index + 1}. ${powerPlate} / ${trailerPlate}`,
- trailerPlate,
-}));
-
const toIsoDateTime = (value: string) => {
if (!value) return undefined;
const date = new Date(value);
@@ -78,7 +55,7 @@ const lineValue = (notes: string | null | undefined, label: string) => {
};
const lineNumber = (notes: string | null | undefined, label: string): number | '' => {
- const value = lineValue(notes, label).replace(/\s*kg$/i, '');
+ const value = lineValue(notes, label).replace(/\s*(kg|t)$/i, '');
if (!value) return '';
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : '';
@@ -141,6 +118,13 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
queryFn: () => warehouseService.getCustomerTrucks(bookingId as string),
enabled: opened && Boolean(bookingId),
});
+ // Per-container cargo weights — the truck's net (gross − tare) must equal the
+ // total cargo weight of the containers selected as loaded on it.
+ const { data: containerWeights = [] } = useQuery({
+ queryKey: ['release-container-weights', bookingId],
+ queryFn: () => warehouseService.getContainerWeights(bookingId as string),
+ enabled: opened && Boolean(bookingId),
+ });
const [reference, setReference] = useState('');
const [truckPlateNumber, setTruckPlateNumber] = useState('');
const [trailerPlateNumber, setTrailerPlateNumber] = useState('');
@@ -210,22 +194,39 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
truckType: t.truckType,
})),
];
- const truckSelectOptions = [
- ...assignedTruckOptions,
- ...REGISTERED_FIRST_LAST_MILE_TRUCKS.map((t) => ({
- value: t.value,
- label: t.label,
- trailerPlate: t.trailerPlate,
- driverName: '',
- driverPhone: '',
- truckType: '',
- })),
- ];
+ // Only trucks actually assigned to THIS booking (last-mile prefill or customer
+ // portal) are selectable. No global fleet list — if nothing is assigned, the
+ // operator types the plate manually in the field below.
+ const truckSelectOptions = assignedTruckOptions;
// Neither a last-mile truck nor a customer truck has been assigned yet.
const noTruckAssigned = assignedTruckOptions.length === 0 && !isCustomerAssignedTruck;
const isTruckIdentityLocked = isEntranceLocked || isCustomerAssignedTruck || hasLastMileTruckPrefill;
const isDriverNameLocked = isEntranceLocked || isCustomerAssignedTruck || Boolean(truckPrefill?.driverName);
- const systemNetWeight = item?.weight == null ? netWeight : Number(item.weight);
+
+ // Which containers ride this truck, and their combined cargo weight. When the
+ // booking has container weights, that sum is the authoritative net; the
+ // operator selects the containers loaded on the truck at exit.
+ const hasContainerWeights = containerWeights.length > 0;
+ const containerWeightByNumber = new Map(
+ containerWeights.map((c) => [c.containerNumber.toUpperCase(), Number(c.weightTons) || 0]),
+ );
+ const containerSelectData = containerWeights.map((c) => ({
+ value: c.containerNumber,
+ label: `${c.containerNumber} · ${(Number(c.weightTons) || 0).toLocaleString()} t`,
+ }));
+ const selectedContainerNumbers = containerNumbers.map((n) => n.trim()).filter(Boolean);
+ const selectedCargoWeight = Number(
+ selectedContainerNumbers
+ .reduce((sum, n) => sum + (containerWeightByNumber.get(n.toUpperCase()) ?? 0), 0)
+ .toFixed(3),
+ );
+ const useContainerNet = hasContainerWeights && selectedContainerNumbers.length > 0;
+
+ const systemNetWeight = useContainerNet
+ ? selectedCargoWeight
+ : item?.weight == null
+ ? netWeight
+ : Number(item.weight);
const computedNetWeight =
tareWeight !== '' && grossWeight !== '' ? Number((Number(grossWeight) - Number(tareWeight)).toFixed(3)) : null;
const weightMismatch =
@@ -246,6 +247,10 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
toast({ variant: 'destructive', title: 'Gate out time and gross weight are required' });
return;
}
+ if (isExitStep && hasContainerWeights && selectedContainerNumbers.length === 0) {
+ toast({ variant: 'destructive', title: 'Select the containers loaded on this truck' });
+ return;
+ }
if (isExitStep && systemNetWeight === '') {
toast({ variant: 'destructive', title: 'System recorded net weight is missing' });
return;
@@ -336,23 +341,27 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
Truck is not assigned yet — assign a last-mile or customer truck, or enter the plate manually below.
)}
- truck.value === truckPlateNumber) ? truckPlateNumber : null}
- onChange={(value) => {
- const truck = truckSelectOptions.find((row) => row.value === value);
- setTruckPlateNumber(truck?.value ?? '');
- setTrailerPlateNumber(truck?.trailerPlate ?? '');
- if (truck?.driverName) setDriverName(truck.driverName);
- if (truck?.driverPhone) setDriverPhone(truck.driverPhone);
- if (truck?.truckType) setTruckType(truck.truckType);
- }}
- />
+ {truckSelectOptions.length > 0 && (
+ truck.value === truckPlateNumber) ? truckPlateNumber : null}
+ onChange={(value) => {
+ const truck = truckSelectOptions.find((row) => row.value === value);
+ setTruckPlateNumber(truck?.value ?? '');
+ setTrailerPlateNumber(truck?.trailerPlate ?? '');
+ if (truck?.driverName) setDriverName(truck.driverName);
+ if (truck?.driverPhone) setDriverPhone(truck.driverPhone);
+ if (truck?.truckType) setTruckType(truck.truckType);
+ }}
+ />
+ )}
setDriverPhone(e.currentTarget.value)} readOnly={isEntranceLocked} />
setTruckType(e.currentTarget.value)} readOnly={isTruckIdentityLocked} />
-
-
- 1 ? 2 : 1} spacing="sm">
- {containerNumbers.map((containerNumber, index) => (
- 1 ? `Container number ${index + 1}` : 'Container number'}
- value={containerNumber}
- onChange={(e) =>
- setContainerNumbers((numbers) =>
- numbers.map((number, numberIndex) => (numberIndex === index ? e.currentTarget.value : number)),
- )
- }
- readOnly={isTruckIdentityLocked}
- />
- ))}
-
-
+
+ {hasContainerWeights ? (
+ setContainerNumbers(values.length ? values : [''])}
+ />
+ ) : (
+
+ 1 ? 2 : 1} spacing="sm">
+ {containerNumbers.map((containerNumber, index) => (
+ 1 ? `Container number ${index + 1}` : 'Container number'}
+ value={containerNumber}
+ onChange={(e) =>
+ setContainerNumbers((numbers) =>
+ numbers.map((number, numberIndex) => (numberIndex === index ? e.currentTarget.value : number)),
+ )
+ }
+ readOnly={isTruckIdentityLocked}
+ />
+ ))}
+
+
+ )}
setGateInTime(e.currentTarget.value)} readOnly={isEntranceLocked} />
- setTareWeight(v === '' ? '' : Number(v))} readOnly={isEntranceLocked} />
- setGrossWeight(v === '' ? '' : Number(v))} disabled={!isExitStep} />
-
+ setTareWeight(v === '' ? '' : Number(v))} readOnly={isEntranceLocked} />
+ setGrossWeight(v === '' ? '' : Number(v))} disabled={!isExitStep} />
+
- Computed net: {computedNetWeight == null ? '-' : `${computedNetWeight.toLocaleString()} kg`}
+ Computed net: {computedNetWeight == null ? '-' : `${computedNetWeight.toLocaleString()} t`}
setGateOutTime(e.currentTarget.value)} disabled={!isExitStep} />
diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/StoreInventoryModal.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/StoreInventoryModal.tsx
new file mode 100644
index 000000000..f1a5aa4ad
--- /dev/null
+++ b/apps/edr-freight-web/backoffice/src/components/warehouses/StoreInventoryModal.tsx
@@ -0,0 +1,143 @@
+import { useEffect, useMemo, useState } from 'react';
+import { Alert, Button, Group, Modal, Select, Stack, Text } from '@mantine/core';
+import { Info } from 'lucide-react';
+
+import { useMutation, useQuery } from '@tanstack/react-query';
+
+import { api } from '@/services/api';
+import { useToast } from '@/hooks/use-toast';
+import type { WarehouseInventoryItem } from '@/types/warehouse';
+import { extractErrorMessage } from './options';
+
+interface StoreInventoryModalProps {
+ opened: boolean;
+ onClose: () => void;
+ item: WarehouseInventoryItem | null;
+}
+
+/**
+ * Store an unloaded import item. The operator may pick warehouse → yard → zone
+ * explicitly; leaving them blank falls back to the backend auto allocation.
+ */
+export function StoreInventoryModal({ opened, onClose, item }: StoreInventoryModalProps) {
+ const { toast } = useToast();
+ const storeMutation = useMutation(api.warehouses.store.mutationOptions());
+ const [warehouseId, setWarehouseId] = useState('');
+ const [yardId, setYardId] = useState('');
+ const [zoneId, setZoneId] = useState('');
+
+ useEffect(() => {
+ if (opened) {
+ setWarehouseId('');
+ setYardId('');
+ setZoneId('');
+ }
+ }, [opened]);
+
+ const warehousesQuery = useQuery(
+ api.warehouses.list.queryOptions({ input: { filter: { status: 'ACTIVE' } } }),
+ );
+ const yardsQuery = useQuery(
+ api.warehouses.listYards.queryOptions({
+ input: { warehouseId },
+ enabled: Boolean(warehouseId),
+ }),
+ );
+ const zonesQuery = useQuery(
+ api.warehouses.listZones.queryOptions({
+ input: { yardId },
+ enabled: Boolean(yardId),
+ }),
+ );
+
+ const warehouseOptions = useMemo(
+ () => (warehousesQuery.data ?? []).map((w) => ({ value: w.id, label: `${w.name} (${w.code})` })),
+ [warehousesQuery.data],
+ );
+ const yardOptions = useMemo(
+ () => (yardsQuery.data ?? []).filter((y) => y.status === 'ACTIVE').map((y) => ({ value: y.id, label: `${y.name} (${y.code})` })),
+ [yardsQuery.data],
+ );
+ const zoneOptions = useMemo(
+ () => (zonesQuery.data ?? []).filter((z) => z.status === 'ACTIVE').map((z) => ({ value: z.id, label: `${z.name} (${z.code})` })),
+ [zonesQuery.data],
+ );
+
+ const isManual = Boolean(warehouseId || yardId || zoneId);
+ const manualComplete = Boolean(warehouseId && yardId && zoneId);
+
+ const handleSubmit = async () => {
+ if (!item) return;
+ if (isManual && !manualComplete) {
+ toast({ variant: 'destructive', title: 'Pick warehouse, yard and zone — or clear all to auto-allocate' });
+ return;
+ }
+ try {
+ await storeMutation.mutateAsync({
+ id: item.id,
+ payload: manualComplete ? { warehouseId, yardId, zoneId } : undefined,
+ });
+ toast({ title: manualComplete ? 'Inventory stored at selected location' : 'Inventory stored (auto-allocated)' });
+ onClose();
+ } catch (error) {
+ toast({ variant: 'destructive', title: 'Store failed', description: extractErrorMessage(error) });
+ }
+ };
+
+ return (
+
+
+ } color="blue" variant="light">
+
+ Choose a warehouse, yard and zone to store this item at a specific location, or leave them
+ blank to let the system auto-allocate by rule / available capacity.
+
+
+ {
+ setWarehouseId(v ?? '');
+ setYardId('');
+ setZoneId('');
+ }}
+ />
+ {
+ setYardId(v ?? '');
+ setZoneId('');
+ }}
+ />
+ setZoneId(v ?? '')}
+ />
+
+
+ Cancel
+
+
+ {manualComplete ? 'Store here' : 'Store (auto)'}
+
+
+
+
+ );
+}
diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/WarehouseInventoryTable.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/WarehouseInventoryTable.tsx
index b1670fa56..871780bbf 100644
--- a/apps/edr-freight-web/backoffice/src/components/warehouses/WarehouseInventoryTable.tsx
+++ b/apps/edr-freight-web/backoffice/src/components/warehouses/WarehouseInventoryTable.tsx
@@ -41,7 +41,6 @@ const itemKind = (item: WarehouseInventoryItem) => {
const actionColor: Record = {
store: 'blue',
- reserve: 'grape',
'ready-for-loading': 'cyan',
load: 'teal',
dispatch: 'edr-green',
diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/options.ts b/apps/edr-freight-web/backoffice/src/components/warehouses/options.ts
index 42663877d..3a71ea2a5 100644
--- a/apps/edr-freight-web/backoffice/src/components/warehouses/options.ts
+++ b/apps/edr-freight-web/backoffice/src/components/warehouses/options.ts
@@ -57,3 +57,24 @@ export const extractErrorMessage = (error: unknown, fallback = 'Something went w
const rawMessage = data?.message ?? data?.error ?? (error as Error)?.message;
return Array.isArray(rawMessage) ? rawMessage.join(', ') : rawMessage ? String(rawMessage) : fallback;
};
+
+/**
+ * Error extractor for blob-download requests. When `responseType: 'blob'`, axios
+ * delivers the JSON error body as a Blob, so `extractErrorMessage` can't read
+ * `.message`. Decode the Blob to text, parse it, then fall back to the sync path.
+ */
+export const extractDownloadErrorMessage = async (error: unknown, fallback = 'Something went wrong') => {
+ const responseData = (error as { response?: { data?: unknown } })?.response?.data;
+ if (responseData instanceof Blob) {
+ try {
+ const text = await responseData.text();
+ const parsed = JSON.parse(text) as Record;
+ const raw = parsed?.message ?? parsed?.error;
+ if (Array.isArray(raw)) return raw.join(', ');
+ if (raw) return String(raw);
+ } catch {
+ /* not JSON — fall through */
+ }
+ }
+ return extractErrorMessage(error, fallback);
+};
diff --git a/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts b/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts
index bd1c51e47..2ceaae870 100644
--- a/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts
+++ b/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts
@@ -38,6 +38,8 @@ export const QUERY_KEYS = {
documents: (id: string) =>
["customers", "detail", id, "documents"] as const,
payments: (id: string) => ["customers", "detail", id, "payments"] as const,
+ changeRequests: (id: string) =>
+ ["customers", "detail", id, "change-requests"] as const,
},
INVOICES: {
diff --git a/apps/edr-freight-web/backoffice/src/constants/URLS.ts b/apps/edr-freight-web/backoffice/src/constants/URLS.ts
index 05bb06372..77a3bc95e 100644
--- a/apps/edr-freight-web/backoffice/src/constants/URLS.ts
+++ b/apps/edr-freight-web/backoffice/src/constants/URLS.ts
@@ -76,6 +76,12 @@ export const URL_CONSTANTS = {
DOCUMENTS: (id: string) => `/companies/${id}/documents`,
PROFILE_STATUS: (profileId: string) =>
`/companies/company-profiles/${profileId}/status`,
+ CHANGE_REQUESTS: (companyId: string) =>
+ `/companies/${companyId}/change-requests`,
+ CHANGE_REQUEST_APPROVE: (id: string) =>
+ `/companies/change-requests/${id}/approve`,
+ CHANGE_REQUEST_REJECT: (id: string) =>
+ `/companies/change-requests/${id}/reject`,
BOOKINGS_CUSTOMER_VIEW: (id: string) =>
`/bookings/by-company/${id}/customer-view`,
PAYMENTS_CUSTOMER_VIEW: (id: string) =>
diff --git a/apps/edr-freight-web/backoffice/src/lib/permissions.ts b/apps/edr-freight-web/backoffice/src/lib/permissions.ts
index c8bfa7c84..e0c5c3d08 100644
--- a/apps/edr-freight-web/backoffice/src/lib/permissions.ts
+++ b/apps/edr-freight-web/backoffice/src/lib/permissions.ts
@@ -145,6 +145,7 @@ export const FREIGHT_PERMS = {
},
tracking: {
view: "edr_freight_app:tracking:view",
+ manage: "edr_freight_app:tracking:manage",
},
fuel: {
view: "edr_freight_app:fuel:view",
diff --git a/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx
index ad888b1f6..c68a4ca25 100644
--- a/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx
+++ b/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx
@@ -1,6 +1,7 @@
import {
ActionIcon,
Anchor,
+ Badge,
Box,
Button,
Card,
@@ -32,6 +33,8 @@ import { useNavigate, useParams } from "react-router-dom";
import {
BookingStatusBadge,
+ ChangeRequestPendingBadge,
+ ChangeRequestReview,
CompanyStatusBadge,
CompanyTypeBadge,
InvoiceStatusBadge,
@@ -161,25 +164,85 @@ export default function CustomerDetailPage() {
{
id: "type",
header: "Role",
- cell: ({ row }) => ,
- },
- {
- id: "reference",
- header: "Reference",
cell: ({ row }) => (
-
- {row.original.reference}
-
+
+
+
+
+ {row.original.reference}
+
+
),
},
{
- id: "businessLicense",
- header: "Business license",
- cell: ({ row }) => (
-
- {row.original.businessLicense || "—"}
-
- ),
+ id: "licenseFiles",
+ header: "License documents",
+ cell: ({ row }) => {
+ const files = row.original.licenseFiles ?? [];
+ if (files.length === 0) {
+ return (
+
+ —
+
+ );
+ }
+ return (
+
+ {files.map((f) => (
+
+
+ view({
+ name: f.name,
+ url: fileViewUrl(f.id),
+ mimeType: f.mimeType,
+ })
+ }
+ >
+
+
+
+ view({
+ name: f.name,
+ url: fileViewUrl(f.id),
+ mimeType: f.mimeType,
+ })
+ }
+ style={{
+ maxWidth: 170,
+ textAlign: "left",
+ textDecoration:
+ f.status === "pending_remove"
+ ? "line-through"
+ : undefined,
+ }}
+ >
+ {f.name}
+
+ {f.status === "pending_add" && (
+
+ Pending
+
+ )}
+ {f.status === "pending_remove" && (
+
+ Removing
+
+ )}
+
+ ))}
+
+ );
+ },
},
{
id: "status",
@@ -207,7 +270,7 @@ export default function CustomerDetailPage() {
),
},
],
- [],
+ [view],
);
const bookingColumns: ColumnDef[] = useMemo(
@@ -501,13 +564,13 @@ export default function CustomerDetailPage() {
]}
backTo="/dashboard/customers"
title={company.name}
- subtitle={`TIN ${company.tin}${
- company.country ? ` · ${company.country}` : ""
- }`}
+ subtitle={`TIN ${company.tin}${company.country ? ` · ${company.country}` : ""
+ }`}
meta={
+
}
/>
@@ -534,6 +597,8 @@ export default function CustomerDetailPage() {
{/* OVERVIEW */}
+
+
-
+
void bookingsQuery.refetch(),
- }
+ message: "Failed to load bookings.",
+ onRetry: () => void bookingsQuery.refetch(),
+ }
: undefined
}
/>
@@ -663,9 +728,9 @@ export default function CustomerDetailPage() {
error={
documentsQuery.isError
? {
- message: "Failed to load documents.",
- onRetry: () => void documentsQuery.refetch(),
- }
+ message: "Failed to load documents.",
+ onRetry: () => void documentsQuery.refetch(),
+ }
: undefined
}
/>
@@ -679,12 +744,12 @@ export default function CustomerDetailPage() {
{licenseProfiles.map((p) => (
-
+
{humanize(p.type)} · {p.reference}
{(p.licenseFiles ?? []).map((f) => (
-
+
view({
name: f.name,
- url: f.url,
+ url: fileViewUrl(f.id),
mimeType: f.mimeType,
})
}
size="xs"
+ style={{
+ textDecoration:
+ f.status === "pending_remove"
+ ? "line-through"
+ : undefined,
+ }}
>
{f.name}
+ {f.status === "pending_add" && (
+
+ Pending approval
+
+ )}
+ {f.status === "pending_remove" && (
+
+ Removal pending
+
+ )}
))}
+ {(p.licenseFiles ?? []).length === 0 && (
+
+ No license documents.
+
+ )}
))}
@@ -723,9 +809,9 @@ export default function CustomerDetailPage() {
error={
paymentsQuery.isError
? {
- message: "Failed to load payments.",
- onRetry: () => void paymentsQuery.refetch(),
- }
+ message: "Failed to load payments.",
+ onRetry: () => void paymentsQuery.refetch(),
+ }
: undefined
}
/>
@@ -746,9 +832,9 @@ export default function CustomerDetailPage() {
error={
invoicesQuery.isError
? {
- message: "Failed to load invoices.",
- onRetry: () => void invoicesQuery.refetch(),
- }
+ message: "Failed to load invoices.",
+ onRetry: () => void invoicesQuery.refetch(),
+ }
: undefined
}
pagination={{
diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/TrackingPage.tsx b/apps/edr-freight-web/backoffice/src/pages/fleet/TrackingPage.tsx
index 14bb32d33..958d99775 100644
--- a/apps/edr-freight-web/backoffice/src/pages/fleet/TrackingPage.tsx
+++ b/apps/edr-freight-web/backoffice/src/pages/fleet/TrackingPage.tsx
@@ -27,6 +27,8 @@ import {
import { Activity, Pencil, Plus, Radio, Trash2 } from "lucide-react";
import Breadcrumbs from "@/components/ui/Breadcrumbs";
import { useToast } from "@/hooks/use-toast";
+import { useAuth } from "@/auth/useAuth";
+import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
import { vehiclesService } from "@/services/vehicles.service";
import { gpsTrackingService, type GpsDevice } from "@/services/gps-tracking.service";
import { freightBrand } from "@/theme/freight-brand";
@@ -151,6 +153,8 @@ function RouteTrail({ path }: { path: LatLng[] }) {
export function TrackingPage() {
const { toast } = useToast();
const qc = useQueryClient();
+ const { user } = useAuth();
+ const canManage = hasPermission(user, FREIGHT_PERMS.tracking.manage);
const [selectedId, setSelectedId] = useState(null);
const [hoverId, setHoverId] = useState(null);
const [mapsReady, setMapsReady] = useState(false);
@@ -288,9 +292,11 @@ export function TrackingPage() {
Real-Time Vehicle Tracking
Live GPS positions from GT06 trackers
- } color="edr-green" onClick={openRegister}>
- Register tracker
-
+ {canManage && (
+ } color="edr-green" onClick={openRegister}>
+ Register tracker
+
+ )}
@@ -358,9 +364,11 @@ export function TrackingPage() {
}>
{selected.online ? "Live" : "Offline"}
- deleteMutation.mutate(selected.id)}>
-
-
+ {canManage && (
+ deleteMutation.mutate(selected.id)}>
+
+
+ )}
@@ -393,6 +401,7 @@ export function TrackingPage() {
data={vehicleOptions}
value={selected.vehicleId ?? null}
onChange={(v) => assignMutation.mutate({ id: selected.id, vehicleId: v })}
+ disabled={!canManage}
searchable
clearable
/>
@@ -421,14 +430,16 @@ export function TrackingPage() {
{d.online ? "Live" : "Offline"}
- { e.stopPropagation(); openEdit(d); }}
- >
-
-
+ {canManage && (
+ { e.stopPropagation(); openEdit(d); }}
+ >
+
+
+ )}
diff --git a/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx b/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx
index d57210e1e..f3235e0d1 100644
--- a/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx
+++ b/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx
@@ -286,6 +286,9 @@ const toReleaseInventoryItem = (row: ImportUnloadedItem): WarehouseInventoryItem
handoverDocumentReference: row.handoverDocumentReference,
handoverDocumentDate: row.handoverDocumentDate,
deliveredAt: row.deliveredAt,
+ // Carries the saved [Exit Inspection] block so truck-leaving prefills the
+ // details captured at arrival (plate, driver, tare, gate-in).
+ notes: row.notes,
booking: row.bookingId
? {
id: row.bookingId,
diff --git a/apps/edr-freight-web/backoffice/src/pages/warehouses/LoadedInventoryPage.tsx b/apps/edr-freight-web/backoffice/src/pages/warehouses/LoadedInventoryPage.tsx
index b5deec80e..7dfc1996c 100644
--- a/apps/edr-freight-web/backoffice/src/pages/warehouses/LoadedInventoryPage.tsx
+++ b/apps/edr-freight-web/backoffice/src/pages/warehouses/LoadedInventoryPage.tsx
@@ -38,7 +38,7 @@ const columns: ColumnDef[] = [
},
{
id: 'weight',
- header: 'Loaded Weight (kg)',
+ header: 'Loaded Weight (t)',
cell: ({ row }) => formatNumber(row.original.loadedWeight),
},
{
diff --git a/apps/edr-freight-web/backoffice/src/pages/warehouses/LoadingQueuePage.tsx b/apps/edr-freight-web/backoffice/src/pages/warehouses/LoadingQueuePage.tsx
index 652a26863..e9bb3a162 100644
--- a/apps/edr-freight-web/backoffice/src/pages/warehouses/LoadingQueuePage.tsx
+++ b/apps/edr-freight-web/backoffice/src/pages/warehouses/LoadingQueuePage.tsx
@@ -203,7 +203,7 @@ function PendingPaymentTable({ items, onNavigate }: PendingPaymentTableProps) {
},
{ id: 'warehouse', header: 'Warehouse', cell: ({ row }) => row.original.warehouse?.code ?? '—' },
{ id: 'zone', header: 'Zone', cell: ({ row }) => row.original.zone?.code ?? '—' },
- { id: 'weight', header: 'Weight (kg)', cell: ({ row }) => formatNumber(row.original.weight) },
+ { id: 'weight', header: 'Weight (t)', cell: ({ row }) => formatNumber(row.original.weight) },
{
id: 'payment',
header: 'Payment',
diff --git a/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseRulesPage.tsx b/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseRulesPage.tsx
index 41a97110f..81633f9f8 100644
--- a/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseRulesPage.tsx
+++ b/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseRulesPage.tsx
@@ -16,7 +16,7 @@ import {
Text,
TextInput,
} from '@mantine/core';
-import { Info, Plus, Trash2 } from 'lucide-react';
+import { Info, Pencil, Plus, Trash2 } from 'lucide-react';
import { useQuery } from '@tanstack/react-query';
@@ -30,6 +30,8 @@ import {
useDeleteAllocationRule,
useDeleteFeeRule,
useFeeRules,
+ useUpdateAllocationRule,
+ useUpdateFeeRule,
} from '@/hooks/useWarehouses';
import { api } from '@/services/api';
import {
@@ -38,6 +40,8 @@ import {
FEE_RULE_TYPES,
FEE_RULE_TYPE_LABELS,
VEHICLE_TYPES,
+ type AllocationRule,
+ type FeeRule,
type FeeRuleBasis,
type FeeRuleType,
} from '@/types/warehouse';
@@ -121,8 +125,10 @@ function AllocationRules() {
const { data, isLoading } = useAllocationRules();
const { data: yards = [], isLoading: yardsLoading } = useAllWarehouseYards();
const create = useCreateAllocationRule();
+ const update = useUpdateAllocationRule();
const remove = useDeleteAllocationRule();
const [open, setOpen] = useState(false);
+ const [editingId, setEditingId] = useState(null);
const [form, setForm] = useState({
name: '',
priority: 100,
@@ -142,7 +148,8 @@ function AllocationRules() {
label: `${yard.code} - ${yard.name}${yard.warehouse?.code ? ` (${yard.warehouse.code})` : ''}`,
}));
- const resetForm = () =>
+ const resetForm = () => {
+ setEditingId(null);
setForm({
name: '',
priority: 100,
@@ -153,6 +160,22 @@ function AllocationRules() {
targetYardCode: '',
storageType: '',
});
+ };
+
+ const startEdit = (rule: AllocationRule) => {
+ setForm({
+ name: rule.name,
+ priority: rule.priority ?? 100,
+ freightType: rule.freightType ?? '',
+ tradeDirection: rule.tradeDirection ?? '',
+ cargoTypeCode: rule.cargoTypeCode ?? '',
+ containerStatus: rule.containerStatus ?? '',
+ targetYardCode: rule.targetYardCode ?? '',
+ storageType: rule.storageType ?? '',
+ });
+ setEditingId(rule.id);
+ setOpen(true);
+ };
const submit = async () => {
if (!form.name.trim() || !form.targetYardCode.trim()) {
@@ -160,7 +183,7 @@ function AllocationRules() {
return;
}
- await create.mutateAsync({
+ const payload = {
name: form.name.trim(),
priority: form.priority,
freightType: clean(form.freightType) ?? null,
@@ -170,10 +193,20 @@ function AllocationRules() {
targetYardCode: form.targetYardCode.trim(),
storageType: clean(form.storageType) ?? null,
isActive: true,
- } as never);
- toast({ title: 'Allocation rule created' });
- setOpen(false);
- resetForm();
+ };
+ try {
+ if (editingId) {
+ await update.mutateAsync({ id: editingId, payload: payload as never });
+ toast({ title: 'Allocation rule updated' });
+ } else {
+ await create.mutateAsync(payload as never);
+ toast({ title: 'Allocation rule created' });
+ }
+ setOpen(false);
+ resetForm();
+ } catch (error) {
+ toast({ variant: 'destructive', title: editingId ? 'Update failed' : 'Create failed', description: extractErrorMessage(error) });
+ }
};
return (
@@ -182,7 +215,7 @@ function AllocationRules() {
{rules.length} rule(s) matched by ascending priority
- } onClick={() => setOpen(true)}>
+ } onClick={() => { resetForm(); setOpen(true); }}>
New allocation rule
@@ -229,14 +262,19 @@ function AllocationRules() {
- remove.mutate(rule.id)}
- title="Delete"
- >
-
-
+
+ startEdit(rule)} title="Edit">
+
+
+ remove.mutate(rule.id)}
+ title="Delete"
+ >
+
+
+
))}
@@ -245,7 +283,7 @@ function AllocationRules() {
)}
- setOpen(false)} title="New allocation rule" centered size="lg">
+ { setOpen(false); resetForm(); }} title={editingId ? 'Edit allocation rule' : 'New allocation rule'} centered size="lg">
@@ -340,11 +378,11 @@ function AllocationRules() {
/>
- setOpen(false)}>
+ { setOpen(false); resetForm(); }}>
Cancel
-
- Create
+
+ {editingId ? 'Save changes' : 'Create'}
@@ -363,8 +401,10 @@ function FeeRules() {
api.containerTypes.list.queryOptions({ staleTime: Infinity }),
);
const create = useCreateFeeRule();
+ const update = useUpdateFeeRule();
const remove = useDeleteFeeRule();
const [open, setOpen] = useState(false);
+ const [editingId, setEditingId] = useState(null);
const [form, setForm] = useState({
name: '',
ruleType: 'DEMURRAGE_FEE' as FeeRuleType,
@@ -394,7 +434,8 @@ function FeeRules() {
// Double handling + truck detention apply to IMPORT only — trade direction is locked.
const isImportOnly = isDoubleHandling || isTruckDetention;
- const resetForm = () =>
+ const resetForm = () => {
+ setEditingId(null);
setForm({
name: '',
ruleType: 'DEMURRAGE_FEE',
@@ -410,6 +451,27 @@ function FeeRules() {
tiers: [],
currency: 'USD',
});
+ };
+
+ const startEdit = (rule: FeeRule) => {
+ setForm({
+ name: rule.name,
+ ruleType: rule.ruleType,
+ basis: (rule.basis as FeeRuleBasis) ?? 'PER_CONTAINER',
+ freightType: rule.freightType ?? '',
+ tradeDirection: rule.tradeDirection ?? '',
+ cargoTypeCode: rule.cargoTypeCode ?? '',
+ containerType: rule.containerType ?? '',
+ vehicleType: rule.vehicleType ?? '',
+ freeDays: rule.freeDays ?? 3,
+ freeHours: rule.freeHours ?? 3,
+ ratePerDay: rule.ratePerDay ?? 0,
+ tiers: (rule.tiers ?? []).map((t) => ({ fromDay: t.fromDay, toDay: t.toDay, ratePerDay: t.ratePerDay })),
+ currency: rule.currency ?? 'USD',
+ });
+ setEditingId(rule.id);
+ setOpen(true);
+ };
const addTier = () =>
setForm((f) => {
@@ -479,12 +541,17 @@ function FeeRules() {
};
try {
- await create.mutateAsync(payload as never);
- toast({ title: 'Fee rule created' });
+ if (editingId) {
+ await update.mutateAsync({ id: editingId, payload: payload as never });
+ toast({ title: 'Fee rule updated' });
+ } else {
+ await create.mutateAsync(payload as never);
+ toast({ title: 'Fee rule created' });
+ }
setOpen(false);
resetForm();
} catch (error) {
- if (tiers.length && isUnknownTiersError(error)) {
+ if (!editingId && tiers.length && isUnknownTiersError(error)) {
const legacyPayload: Omit = {
name: payload.name,
ruleType: payload.ruleType,
@@ -505,7 +572,7 @@ function FeeRules() {
resetForm();
return;
}
- toast({ variant: 'destructive', title: 'Create failed', description: extractErrorMessage(error) });
+ toast({ variant: 'destructive', title: editingId ? 'Update failed' : 'Create failed', description: extractErrorMessage(error) });
}
};
@@ -515,7 +582,7 @@ function FeeRules() {
{rules.length} rule(s) - most specific match applies
- } onClick={() => setOpen(true)}>
+ } onClick={() => { resetForm(); setOpen(true); }}>
New fee rule
@@ -577,14 +644,19 @@ function FeeRules() {
- remove.mutate(rule.id)}
- title="Delete"
- >
-
-
+
+ startEdit(rule)} title="Edit">
+
+
+ remove.mutate(rule.id)}
+ title="Delete"
+ >
+
+
+
))}
@@ -593,7 +665,7 @@ function FeeRules() {
)}
- setOpen(false)} title="New fee rule" centered size="lg">
+ { setOpen(false); resetForm(); }} title={editingId ? 'Edit fee rule' : 'New fee rule'} centered size="lg">
)}
- setOpen(false)}>
+ { setOpen(false); resetForm(); }}>
Cancel
-
- Create
+
+ {editingId ? 'Save changes' : 'Create'}
diff --git a/apps/edr-freight-web/backoffice/src/services/api.ts b/apps/edr-freight-web/backoffice/src/services/api.ts
index c991c54f6..c04c67169 100644
--- a/apps/edr-freight-web/backoffice/src/services/api.ts
+++ b/apps/edr-freight-web/backoffice/src/services/api.ts
@@ -3,6 +3,7 @@ import type { FleetResourceSlug } from "@/pages/fleet/config/resources";
import type { BookingDetail } from "@/types/booking";
import type {
Company,
+ CompanyChangeRequest,
CompanyListFilter,
CompanyProfile,
CompanyStats,
@@ -99,6 +100,7 @@ import type {
LoadInventoryPayload,
LoadPassedExportResult,
MoveInventoryPayload,
+ StoreInventoryPayload,
PayInvoicePayload,
ReadyToLoadRow,
ReceiveInventoryPayload,
@@ -1051,10 +1053,13 @@ export const api = {
() => [["warehouse-inventory"], ["warehouses"]],
),
- store: endpoint(
+ store: endpoint<
+ { id: string; payload?: StoreInventoryPayload },
+ WarehouseInventoryItem
+ >(
"warehouse-inventory",
"store",
- (id) => warehouseService.store(id).then((r) => r.data),
+ ({ id, payload }) => warehouseService.store(id, payload).then((r) => r.data),
undefined,
() => INVENTORY_INVALIDATIONS,
),
@@ -2261,13 +2266,13 @@ export const api = {
),
setProfileStatus: endpoint<
- { profileId: string; status: ProfileStatus },
+ { profileId: string; status: ProfileStatus; note?: string },
CompanyProfile
>(
"customers",
"setProfileStatus",
- ({ profileId, status }) =>
- customersService.setProfileStatus(profileId, status),
+ ({ profileId, status, note }) =>
+ customersService.setProfileStatus(profileId, status, note),
undefined,
(_input, data) => [
QUERY_KEYS.CUSTOMERS.byId(data.companyId),
@@ -2275,6 +2280,37 @@ export const api = {
],
),
+ changeRequests: endpoint<{ id: string }, CompanyChangeRequest[]>(
+ "customers",
+ "changeRequests",
+ ({ id }) => customersService.changeRequests(id),
+ ({ id }) => QUERY_KEYS.CUSTOMERS.changeRequests(id),
+ ),
+
+ approveChangeRequest: endpoint<{ id: string }, CompanyChangeRequest>(
+ "customers",
+ "approveChangeRequest",
+ ({ id }) => customersService.approveChangeRequest(id),
+ undefined,
+ (_input, data) => [
+ QUERY_KEYS.CUSTOMERS.changeRequests(data.companyId),
+ QUERY_KEYS.CUSTOMERS.byId(data.companyId),
+ QUERY_KEYS.CUSTOMERS.ROOT,
+ ],
+ ),
+
+ rejectChangeRequest: endpoint<{ id: string; note: string }, CompanyChangeRequest>(
+ "customers",
+ "rejectChangeRequest",
+ ({ id, note }) => customersService.rejectChangeRequest(id, note),
+ undefined,
+ (_input, data) => [
+ QUERY_KEYS.CUSTOMERS.changeRequests(data.companyId),
+ QUERY_KEYS.CUSTOMERS.byId(data.companyId),
+ QUERY_KEYS.CUSTOMERS.ROOT,
+ ],
+ ),
+
setCompanyStatus: endpoint<{ companyId: string; status: string }, unknown>(
"customers",
"setCompanyStatus",
diff --git a/apps/edr-freight-web/backoffice/src/services/customers.service.ts b/apps/edr-freight-web/backoffice/src/services/customers.service.ts
index 9ac20093c..cafe0aece 100644
--- a/apps/edr-freight-web/backoffice/src/services/customers.service.ts
+++ b/apps/edr-freight-web/backoffice/src/services/customers.service.ts
@@ -2,6 +2,7 @@ import { api as apiClient } from "@/auth/http";
import { URL_CONSTANTS } from "@/constants/URLS";
import type {
Company,
+ CompanyChangeRequest,
CompanyListFilter,
CompanyProfile,
CompanyStats,
@@ -80,11 +81,15 @@ export const customersService = {
.then((r) => r.data);
},
- setProfileStatus(profileId: string, status: ProfileStatus): Promise {
+ setProfileStatus(
+ profileId: string,
+ status: ProfileStatus,
+ note?: string,
+ ): Promise {
return apiClient
.patch(
URL_CONSTANTS.COMPANIES.PROFILE_STATUS(profileId),
- { status },
+ { status, note },
)
.then((r) => r.data);
},
@@ -95,4 +100,32 @@ export const customersService = {
.patch(URL_CONSTANTS.COMPANIES.BY_ID(companyId), { status })
.then((r) => r.data);
},
+
+ /** List a company's profile-edit change requests (newest first). */
+ changeRequests(companyId: string): Promise {
+ return apiClient
+ .get(
+ URL_CONSTANTS.COMPANIES.CHANGE_REQUESTS(companyId),
+ )
+ .then((r) => r.data);
+ },
+
+ /** Approve a pending change request — applies the proposed changes. */
+ approveChangeRequest(id: string): Promise {
+ return apiClient
+ .post(
+ URL_CONSTANTS.COMPANIES.CHANGE_REQUEST_APPROVE(id),
+ )
+ .then((r) => r.data);
+ },
+
+ /** Reject a pending change request with a note. */
+ rejectChangeRequest(id: string, note: string): Promise {
+ return apiClient
+ .post(
+ URL_CONSTANTS.COMPANIES.CHANGE_REQUEST_REJECT(id),
+ { note },
+ )
+ .then((r) => r.data);
+ },
};
diff --git a/apps/edr-freight-web/backoffice/src/services/warehouse.service.ts b/apps/edr-freight-web/backoffice/src/services/warehouse.service.ts
index 16037b199..366d8b9f3 100644
--- a/apps/edr-freight-web/backoffice/src/services/warehouse.service.ts
+++ b/apps/edr-freight-web/backoffice/src/services/warehouse.service.ts
@@ -30,6 +30,7 @@ import type {
LoadableWagon,
LoadInventoryPayload,
MoveInventoryPayload,
+ StoreInventoryPayload,
ReceiveInventoryPayload,
ReleaseOrderPayload,
DeliverInventoryPayload,
@@ -63,7 +64,7 @@ import type {
WarehouseZone,
} from '@/types/warehouse';
-export type ContainerItemStage = 'PENDING' | 'RECEIVED' | 'GRN' | 'LOADED' | 'LEFT' | 'DELIVERED';
+export type ContainerItemStage = 'PENDING' | 'RECEIVED' | 'GRN' | 'ASSIGNED' | 'LOADED' | 'LEFT' | 'DELIVERED';
export interface ContainerItem {
containerNumber: string;
@@ -74,9 +75,12 @@ export interface ContainerItem {
truckPlate: string | null;
truckArrived: boolean;
truckLeft: boolean;
+ /** Operator has loaded this container onto the truck (customer assignment alone is not "loaded"). */
+ loaded: boolean;
bookingReference: string | null;
contractId: string | null;
hasLastMile: boolean;
+ handoverSigned: boolean;
}
/** A pre-dispatch EXPORT train that has inventory waiting to be loaded. */
@@ -135,6 +139,26 @@ export const warehouseService = {
return data?.data ?? data ?? [];
},
+ /** Ask the customer to sign the booking's handover (creates one if none, then notifies). */
+ requestHandoverSignature: async (
+ bookingId: string,
+ ): Promise<{ notified: boolean; reference: string | null; alreadySigned: boolean }> => {
+ const { data } = await apiClient.post(
+ `/warehouse-inventory/bookings/${bookingId}/request-handover-signature`,
+ );
+ return data?.data ?? data;
+ },
+
+ /** A booking's containers with VGM cargo weight (tonnes) for exit weighing. */
+ getContainerWeights: async (
+ bookingId: string,
+ ): Promise> => {
+ const { data } = await apiClient.get(
+ `/warehouse-inventory/bookings/${bookingId}/container-weights`,
+ );
+ return data?.data ?? data ?? [];
+ },
+
/** Booking container numbers not yet loaded onto any truck. */
getLoadableContainers: async (bookingId: string): Promise => {
const { data } = await apiClient.get(
@@ -235,8 +259,8 @@ export const warehouseService = {
}),
// ── Lifecycle (Batch 2) ──────────────────────────────────────────────────
- store: (id: string) =>
- apiClient.post(URL_CONSTANTS.WAREHOUSE_INVENTORY.STORE(id)),
+ store: (id: string, payload?: StoreInventoryPayload) =>
+ apiClient.post(URL_CONSTANTS.WAREHOUSE_INVENTORY.STORE(id), payload),
reserve: (payload: ReserveInventoryPayload) =>
apiClient.post(URL_CONSTANTS.WAREHOUSE_INVENTORY.RESERVE, payload),
markReadyForLoading: (id: string) =>
diff --git a/apps/edr-freight-web/backoffice/src/types/customer.ts b/apps/edr-freight-web/backoffice/src/types/customer.ts
index e92d75c89..8d76571d4 100644
--- a/apps/edr-freight-web/backoffice/src/types/customer.ts
+++ b/apps/edr-freight-web/backoffice/src/types/customer.ts
@@ -30,14 +30,24 @@ export type ProfileType =
| "transporter";
/** Mirrors backend `ProfileStatus`. */
-export type ProfileStatus = "active" | "pending" | "suspended" | "blacklisted";
+export type ProfileStatus =
+ | "active"
+ | "pending"
+ | "rejected"
+ | "suspended"
+ | "blacklisted";
-/** A business-license document uploaded for a company profile. */
+/** Review state of a business-license file (mirrors API ProfileLicenseFileView). */
+export type LicenseFileStatus = "live" | "pending_add" | "pending_remove";
+
+/** A business-license document uploaded for a company profile (FileRecord-backed). */
export interface LicenseFile {
+ id: string;
name: string;
- url: string;
size: number;
- mimeType?: string;
+ mimeType: string;
+ /** `live` = approved; `pending_add`/`pending_remove` = awaiting review. */
+ status: LicenseFileStatus;
}
/** A single role a company is registered for, with its reference code. */
@@ -52,6 +62,39 @@ export interface CompanyProfile {
/** Business-license documents uploaded for this profile. */
licenseFiles?: LicenseFile[];
attributes?: Record | null;
+ /** Reviewer note when the role is rejected. */
+ reviewNote?: string | null;
+ createdAt: string;
+ updatedAt: string;
+}
+
+/** Lifecycle of a staged customer profile-edit review. */
+export type ChangeRequestStatus = "pending" | "approved" | "rejected";
+
+/** A staged business-license add/remove on one profile, awaiting review. */
+export interface LicenseChangeIntent {
+ profileId: string;
+ op: "add" | "remove";
+ fileId: string;
+ fileName?: string;
+}
+
+/**
+ * A staged profile-edit change request. The customer's settings edits land here
+ * (pending) until a reviewer approves (applies them) or rejects (with a note).
+ */
+export interface CompanyChangeRequest {
+ id: string;
+ companyId: string;
+ status: ChangeRequestStatus;
+ /** Proposed field values (the diff payload vs. the live company). */
+ snapshot: Record;
+ documentFileIds: string[];
+ /** Staged business-license add/remove intents attached to this request. */
+ licenseChanges: LicenseChangeIntent[];
+ note: string | null;
+ submittedAt: string | null;
+ reviewedAt: string | null;
createdAt: string;
updatedAt: string;
}
diff --git a/apps/edr-freight-web/backoffice/src/types/warehouse.ts b/apps/edr-freight-web/backoffice/src/types/warehouse.ts
index 48ec6f7b3..e16271880 100644
--- a/apps/edr-freight-web/backoffice/src/types/warehouse.ts
+++ b/apps/edr-freight-web/backoffice/src/types/warehouse.ts
@@ -41,7 +41,6 @@ export type InventoryStatus = (typeof INVENTORY_STATUSES)[number];
export type InventoryAction =
| 'store'
- | 'reserve'
| 'ready-for-loading'
| 'load'
| 'dispatch'
@@ -59,7 +58,7 @@ export const INVENTORY_NEXT_ACTION: Record = {
+ unable_to_log_in: "Incorrect email or password.",
+ invalid_refresh_token: "Your session has expired. Please sign in again.",
+ session_expired: "Your session has expired. Please sign in again.",
+ session_not_found: "Your session has expired. Please sign in again.",
+ user_not_found: "No account found for these credentials.",
+};
+
+const SNAKE_CASE_CODE = /^[a-z0-9]+(?:_[a-z0-9]+)+$/;
+
+function humanizeApiMessage(raw: string): string {
+ const known = API_ERROR_MESSAGES[raw];
+ if (known) return known;
+ if (SNAKE_CASE_CODE.test(raw)) {
+ const text = raw.replaceAll("_", " ");
+ return `${text.charAt(0).toUpperCase()}${text.slice(1)}.`;
+ }
+ return raw;
+}
+
export function extractApiError(err: unknown): ApiError {
if (err && typeof err === "object") {
const obj = err as Record;
@@ -16,8 +42,12 @@ export function extractApiError(err: unknown): ApiError {
const statusCode = response.status as number | undefined;
const data = response.data as Record | undefined;
return {
- code: (data?.error as string) || (data?.message as string) || "api_error",
- message: (data?.message as string) || (data?.error as string) || "An unexpected error occurred",
+ code: (data?.message as string) || (data?.error as string) || "api_error",
+ message: humanizeApiMessage(
+ (data?.message as string) ||
+ (data?.error as string) ||
+ "An unexpected error occurred",
+ ),
statusCode,
};
}
diff --git a/apps/edr-freight-web/portal/src/App.tsx b/apps/edr-freight-web/portal/src/App.tsx
index 7567c485d..697c2f89c 100644
--- a/apps/edr-freight-web/portal/src/App.tsx
+++ b/apps/edr-freight-web/portal/src/App.tsx
@@ -24,6 +24,10 @@ import OnboardingResumeBanner, {
} from "./components/onboarding/OnboardingResumeBanner";
import OnboardingWizardDialog from "./components/onboarding/OnboardingWizardDialog";
import useAuth from "./hooks/useAuth";
+import {
+ startTokenRefreshScheduler,
+ stopTokenRefreshScheduler,
+} from "./utils/refreshScheduler";
import EDRFreightLandingPage from "./pages/EDRFreightLandingPage";
import MyPortalPage from "./pages/MyPortalPage";
import MySignaturePage from "./pages/MySignaturePage";
@@ -212,7 +216,20 @@ const sidebarItems: SidebarItem[] = [
const App = () => {
const navigate = useNavigate();
const location = useLocation();
- const { user, company, companyType, createProfileAndSwitch } = useAuth();
+ const { user, company, companyType, createProfileAndSwitch, isAuthenticated } =
+ useAuth();
+
+ // Keep the server session alive while a user is logged in. Runs after
+ // login, signup, and page-reload bootstrap alike.
+ useEffect(() => {
+ if (!isAuthenticated) {
+ stopTokenRefreshScheduler();
+ return;
+ }
+
+ startTokenRefreshScheduler();
+ return stopTokenRefreshScheduler;
+ }, [isAuthenticated]);
const displayName = user?.name?.en || user?.username || user?.email || "User";
const userEmail = user?.email;
diff --git a/apps/edr-freight-web/portal/src/components/onboarding/OnboardingResumeBanner.tsx b/apps/edr-freight-web/portal/src/components/onboarding/OnboardingResumeBanner.tsx
index d0024c6f6..21d4d0746 100644
--- a/apps/edr-freight-web/portal/src/components/onboarding/OnboardingResumeBanner.tsx
+++ b/apps/edr-freight-web/portal/src/components/onboarding/OnboardingResumeBanner.tsx
@@ -1,5 +1,6 @@
import { useQuery } from "@tanstack/react-query";
-import { ArrowRight, Clock } from "lucide-react";
+import { Link } from "react-router-dom";
+import { AlertTriangle, ArrowRight, Clock } from "lucide-react";
import { api } from "@/services/api";
import useAuth from "@/hooks/useAuth";
import type { OnboardingRequirements } from "@/services/companies.service";
@@ -148,16 +149,74 @@ export default function OnboardingResumeBanner({
}
/**
- * Shown once onboarding is submitted but the company's operational profiles are
- * still being reviewed. Communicates that approval is per-profile and that
- * bookings unlock as each profile is cleared. Self-hides when nothing is pending.
+ * Post-onboarding review banner. Surfaces (in priority order):
+ * 1. A pending profile-edit review — the whole account is locked until an admin
+ * approves the submitted changes.
+ * 2. A rejected profile-edit review — links to Settings to amend & resubmit.
+ * 3. Per-operational-profile approval — bookings unlock as each role clears.
+ * Self-hides when there's nothing outstanding.
*/
export function AccountReviewBanner() {
- const { company } = useAuth();
+ const { company, reviewStatus, reviewNote } = useAuth();
const profiles = company?.company?.companyProfiles ?? [];
const pending = profiles.filter((p) => p.status === "pending");
const approved = profiles.filter((p) => p.status === "active");
+ // 1. Profile-edit review pending — the account-wide lock.
+ if (reviewStatus === "pending") {
+ return (
+
+
+
+
+
+
+
+ Your profile changes are under review
+
+
+ Editing and creating new contracts or bookings is paused until an
+ administrator approves your submitted changes.
+
+
+
+
+ );
+ }
+
+ // 2. Profile-edit review rejected — prompt to fix & resubmit.
+ if (reviewStatus === "rejected") {
+ return (
+
+
+
+
+
+
+
+
+ Your recent changes were not approved
+
+
+ {reviewNote
+ ? `Reviewer note: ${reviewNote}`
+ : "Please update your details and resubmit for review."}
+
+
+
+
+ Review & resubmit
+
+
+
+
+ );
+ }
+
+ // 3. Per-operational-profile approval (existing behaviour).
if (profiles.length === 0 || pending.length === 0) return null;
const pendingLabel = pending
diff --git a/apps/edr-freight-web/portal/src/components/onboarding/RoleLicenseStep.tsx b/apps/edr-freight-web/portal/src/components/onboarding/RoleLicenseStep.tsx
index fa1061622..28829c193 100644
--- a/apps/edr-freight-web/portal/src/components/onboarding/RoleLicenseStep.tsx
+++ b/apps/edr-freight-web/portal/src/components/onboarding/RoleLicenseStep.tsx
@@ -4,6 +4,7 @@ import { Paperclip } from "lucide-react";
import { SmartFileInput } from "@edr/ui-common";
import type { IFileUploadSetting } from "@edr/types/freight";
+import { fileViewUrl } from "@/constants/apiConfig";
import type { LicenseFile } from "@/services/companies.service";
const ROLE_LABELS: Record = {
@@ -107,10 +108,10 @@ export default function RoleLicenseStep({
{hasExisting && (
{profile.existingFiles.map((f) => (
-
+
`/api/companies/${id}/documents`,
PROFILE_LICENSE: (profileId: string) =>
`/api/companies/company-profiles/${profileId}/license`,
+ PROFILE_LICENSE_FILE: (profileId: string, fileId: string) =>
+ `/api/companies/company-profiles/${profileId}/license/${fileId}`,
+ PROFILE_LICENSE_REPLACE: (profileId: string, fileId: string) =>
+ `/api/companies/company-profiles/${profileId}/license/${fileId}/replace`,
+ PROFILE_CHANGE_REQUEST: "/api/companies/profile/change-request",
+ PROFILE_REAPPLY: (profileId: string) =>
+ `/api/companies/company-profiles/${profileId}/reapply`,
},
BOOKINGS: {
diff --git a/apps/edr-freight-web/portal/src/hooks/useAuth.ts b/apps/edr-freight-web/portal/src/hooks/useAuth.ts
index 9d17b1fed..0f6e98377 100644
--- a/apps/edr-freight-web/portal/src/hooks/useAuth.ts
+++ b/apps/edr-freight-web/portal/src/hooks/useAuth.ts
@@ -44,7 +44,21 @@ const useAuth = () => {
api.companies.getInfo.queryOptions({
enabled: !!authQuery.data?.id,
retry: false,
- staleTime: 10 * 60 * 1000,
+
+ staleTime(query) {
+ // Fast-poll while anything is awaiting a backoffice decision: an
+ // unapproved role, or a pending profile-edit review. This surfaces
+ // approvals/rejections to the portal within a minute.
+ if (
+ query.state.data?.review?.status === "pending" ||
+ query.state.data?.company?.companyProfiles?.find(
+ (p) => p.status !== "active",
+ )
+ )
+ return 60;
+
+ return 10 * 60 * 1000;
+ },
refetchOnWindowFocus: false,
}),
);
@@ -166,6 +180,14 @@ const useAuth = () => {
const activeProfileStatus = activeProfile?.status ?? null;
const canBook = activeProfileStatus === "active";
+ // Profile-edit review: while a change request is pending the customer is
+ // locked out of editing and of creating new contracts/bookings; a rejected
+ // request surfaces the reviewer note so they can amend and resubmit.
+ const review = companyInfo?.review ?? null;
+ const reviewStatus = review?.status ?? null;
+ const reviewNote = review?.note ?? null;
+ const isUnderReview = reviewStatus === "pending";
+
/** Refetch everything scoped to the active operational profile. */
const invalidateScopedData = async () => {
await Promise.all([
@@ -179,9 +201,7 @@ const useAuth = () => {
]);
};
- const switchMode = async (
- type: ProfileTypeValue,
- ): Promise> => {
+ const switchMode = async (type: ProfileTypeValue): Promise> => {
try {
await api.companies.setActiveMode.call({ type });
await invalidateScopedData();
@@ -207,6 +227,19 @@ const useAuth = () => {
}
};
+ /** Resubmit a rejected operational role for approval, then refresh. */
+ const reapplyProfile = async (
+ profileId: string,
+ ): Promise> => {
+ try {
+ await api.companies.reapplyProfile.call({ profileId });
+ await invalidateScopedData();
+ return { success: true, data: undefined };
+ } catch (err) {
+ return { success: false, error: extractApiError(err) };
+ }
+ };
+
const logout = async () => {
try {
await api.auth.logout.call();
@@ -239,10 +272,14 @@ const useAuth = () => {
companyType,
companyStatus,
isCompanyApproved,
+ reviewStatus,
+ reviewNote,
+ isUnderReview,
onboardingCompleted,
onboardingStep,
switchMode,
createProfileAndSwitch,
+ reapplyProfile,
login,
signup,
setPassword,
diff --git a/apps/edr-freight-web/portal/src/pages/SettingsPage.tsx b/apps/edr-freight-web/portal/src/pages/SettingsPage.tsx
index 8c3204123..2a8443812 100644
--- a/apps/edr-freight-web/portal/src/pages/SettingsPage.tsx
+++ b/apps/edr-freight-web/portal/src/pages/SettingsPage.tsx
@@ -1,11 +1,13 @@
import { api } from "@/services/api";
import type { ProfileResponse } from "@/types/profile";
import {
+ Alert,
Badge,
Box,
Card,
Center,
Container,
+ Fieldset,
Group,
Loader,
Stack,
@@ -17,9 +19,11 @@ import {
import { useQuery, useQueryClient } from "@tanstack/react-query";
import {
AlertCircle,
+ AlertTriangle,
BadgeCheck,
Briefcase,
Building2,
+ Clock,
FileCheck,
Globe,
ShieldCheck,
@@ -225,6 +229,9 @@ export default function SettingsPage() {
);
}
+ const reviewStatus = profile.reviewStatus ?? null;
+ const locked = reviewStatus === "pending";
+
return (
@@ -239,6 +246,39 @@ export default function SettingsPage() {
+ {reviewStatus === "pending" && (
+ }
+ title="Changes submitted for review"
+ >
+ Your recent changes are awaiting administrator approval. Editing is
+ disabled until the review is complete — you'll be notified once it's
+ approved or if any changes are requested.
+
+ )}
+ {reviewStatus === "rejected" && (
+ }
+ title="Changes were not approved"
+ >
+
+ {profile.reviewNote && (
+
+ Reviewer note: {profile.reviewNote}
+
+ )}
+
+ Please update the details below and save again to resubmit for
+ review.
+
+
+
+ )}
+
value && setTab(value as SettingsTab)}
@@ -269,20 +309,33 @@ export default function SettingsPage() {
))}
+ {/* While a change request is pending, every panel's inputs + submit
+ buttons are disabled via the native fieldset; tab switching stays
+ enabled so the customer can still review what they submitted. */}
-
+
+
+
-
+
+
+
-
+
+
+
-
+
+
+
-
+
+
+
diff --git a/apps/edr-freight-web/portal/src/pages/accounts/SignupPage.tsx b/apps/edr-freight-web/portal/src/pages/accounts/SignupPage.tsx
index 1b13f7cfc..5746eefd5 100644
--- a/apps/edr-freight-web/portal/src/pages/accounts/SignupPage.tsx
+++ b/apps/edr-freight-web/portal/src/pages/accounts/SignupPage.tsx
@@ -242,270 +242,259 @@ export default function SignupPage() {
return (
-
- { stage === "form" ? (
-
- < PasswordInput
-label = "Confirm password"
-placeholder = "Re-enter your password"
-required
-disabled = { sending }
-error = { errors.confirmPassword?.message }
-{...register("confirmPassword") }
+
-{
- error ? (
- }
+ {error ? (
+ }
>
- { error }
-
+ {error}
+
) : null}
- : undefined}
+ color="edr-green"
+ fullWidth
+ loading={sending}
+ rightSection={!sending ? : undefined}
>
- Continue
-
+ Continue
+
- < p className = "text-center text-sm text-gray-500" >
- Already have an account ? { " "}
- < button
- type = "button"
-onClick = {() => navigate("/login")}
-className = "font-semibold text-primary hover:underline"
- >
- Sign In
-
-
-
-
- ) : (
-
-
-
-
-
-
- < div className = "space-y-1.5 text-center" >
-
- Verify your { otpChannel === "email" ? "email" : "phone" }
-
- < p className = "text-sm leading-relaxed text-gray-500" >
- We sent a 6 - digit code to{ " " }
-
- { otpChannel === "email"
- ? maskEmail(pendingData?.email ?? "")
- : maskPhone(pendingData?.phone ?? "")}
-
- .Enter it to finish creating your account.
+
+ Already have an account ?{" "}
+ navigate("/login")}
+ className="font-semibold text-primary hover:underline"
+ >
+ Sign In
+
-
+
+
+ ) : (
+
+
+
+
+
+
+
+
+ Verify your {otpChannel === "email" ? "email" : "phone"}
+
+
+ We sent a 6 - digit code to{" "}
+
+ {otpChannel === "email"
+ ? maskEmail(pendingData?.email ?? "")
+ : maskPhone(pendingData?.phone ?? "")}
+
+ .Enter it to finish creating your account.
+
+
-{
- otpError ? (
- }
+ {otpError ? (
+ }
>
- { otpError }
-
+ {otpError}
+
) : null}
-
-
- Verification code
-
- < PinInput
-length = { 6}
-type = "number"
-oneTimeCode
-value = { otpCode }
-placeholder = "0"
-disabled = { verifying }
-styles = {{ input: { textAlign: "center" } }}
-onChange = { setOtpCode }
- />
-
+
+
+ Verification code
+
+
+
- < Button
-color = "edr-green"
-fullWidth
-loading = { verifying }
-disabled = { verifying || otpCode.trim().length !== 6}
-onClick = { confirmOtp }
- >
- Verify & amp; create account
-
+
+ Verify & create account
+
- < div className = "flex items-center justify-between" >
-
+ }
-disabled = { sending || verifying}
-onClick = {() => {
- setStage("form");
- setOtpError(null);
-}}
+ color="gray"
+ leftSection={ }
+ disabled={sending || verifying}
+ onClick={() => {
+ setStage("form");
+ setOtpError(null);
+ }}
>
- Back
-
- < Button
-variant = "subtle"
-color = "edr-green"
-leftSection = {< RotateCw size = { 14} />}
-disabled = { resendIn > 0 || sending || verifying}
-onClick = { resendOtp }
- >
- { resendIn > 0 ? `Resend in ${resendIn}s` : "Resend code"}
-
-
-
+ Back
+
+ }
+ disabled={resendIn > 0 || sending || verifying}
+ onClick={resendOtp}
+ >
+ {resendIn > 0 ? `Resend in ${resendIn}s` : "Resend code"}
+
+
+
)}
-
-
+
+
);
}
diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx
index 40994d589..7384bbc4d 100644
--- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx
+++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx
@@ -18,6 +18,7 @@ import { ContainersCard } from "./components/ContainersCard";
import { ContractCard } from "./components/ContractCard";
import { CustomerTruckAssignmentCard } from "./components/CustomerTruckAssignmentCard";
import { KeyFactsStrip } from "./components/KeyFactsStrip";
+import { MileSummaryCard } from "./components/MileSummaryCard";
import { BodyGrid, PageShell } from "./components/layout";
import {
CancelledBanner,
@@ -108,6 +109,7 @@ export function ReadonlyBookingView({
: status === "SELECTED_FOR_BATCH");
const canApproveDelivery =
status === "COMPLETED" ||
+ Boolean(booking.handoverAwaitingSignature) ||
(status === "TRUCK_ASSIGNED" && Boolean(booking.customerTruckArrivedAt));
const usesCustomerTruck =
booking.tradeDirection === "IMPORT"
@@ -219,6 +221,8 @@ export function ReadonlyBookingView({
+
+
>
}
right={
diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/CustomerTruckAssignmentCard.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/CustomerTruckAssignmentCard.tsx
index 65af584d3..a8cee69eb 100644
--- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/CustomerTruckAssignmentCard.tsx
+++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/CustomerTruckAssignmentCard.tsx
@@ -15,7 +15,7 @@ import {
} from "@mantine/core";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { Freight } from "@edr/types";
-import { CheckCircle2, Clock, Download, Plus, Trash2, Truck } from "lucide-react";
+import { CheckCircle2, Clock, Download, Pencil, Plus, Trash2, Truck } from "lucide-react";
import { useState } from "react";
import toast from "react-hot-toast";
@@ -63,44 +63,59 @@ export function CustomerTruckAssignmentCard({
const [driverName, setDriverName] = useState("");
const [truckType, setTruckType] = useState("");
const [containers, setContainers] = useState([]);
+ const [editingId, setEditingId] = useState(null);
const [error, setError] = useState(null);
// Container numbers on the booking that aren't already loaded onto a truck.
const assignedNumbers = new Set(
trucks.flatMap((t) => (t.containers ?? []).map((c) => c.containerNumber)),
);
+ // When editing a truck, its own containers stay selectable.
+ const editingOwn = new Set(
+ (trucks.find((t) => t.id === editingId)?.containers ?? []).map((c) => c.containerNumber),
+ );
const availableContainers = (booking.containerNumbers ?? []).filter(
- (n) => !assignedNumbers.has(n),
+ (n) => !assignedNumbers.has(n) || editingOwn.has(n),
);
- // EXPORT trucks deliver known containers (pre-selected). IMPORT trucks don't —
- // staff register + weigh what was loaded when the truck leaves.
- const isExport = booking.tradeDirection === "EXPORT";
-
+ // Both import and export specify the containers each truck carries.
const resetForm = () => {
setPlateNumber("");
setDriverName("");
setTruckType("");
setContainers([]);
+ setEditingId(null);
+ setError(null);
+ };
+
+ const startEdit = (t: Freight.ICustomerTruck) => {
+ setPlateNumber(t.plateNumber ?? "");
+ setDriverName(t.driverName ?? "");
+ setTruckType(t.truckType ?? "");
+ setContainers((t.containers ?? []).map((c) => c.containerNumber));
+ setEditingId(t.id);
setError(null);
};
const addMutation = useMutation({
- mutationFn: () =>
- customerTrucksService.add(booking.id, {
+ mutationFn: () => {
+ const payload = {
truckPlateNumber: plateNumber.trim().toUpperCase(),
driverName: driverName.trim(),
truckType: truckType.trim(),
- // Import: containers are registered + weighed on departure, not here.
- containerNumbers: isExport ? containers : [],
- }),
+ containerNumbers: containers,
+ };
+ return editingId
+ ? customerTrucksService.update(booking.id, editingId, payload)
+ : customerTrucksService.add(booking.id, payload);
+ },
onSuccess: (list) => {
queryClient.setQueryData(trucksKey, list);
+ toast.success(editingId ? "Truck updated" : "Truck added");
resetForm();
onAssigned();
- toast.success("Truck added");
},
- onError: (e) => setError(errorMessage(e, "Could not add truck")),
+ onError: (e) => setError(errorMessage(e, editingId ? "Could not update truck" : "Could not add truck")),
});
const removeMutation = useMutation({
@@ -123,7 +138,7 @@ export function CustomerTruckAssignmentCard({
setError("Plate number, driver name and truck type are required.");
return;
}
- if (isExport && (containers.length < 1 || containers.length > 2)) {
+ if (containers.length < 1 || containers.length > 2) {
setError("Select 1 or 2 container numbers for this truck.");
return;
}
@@ -187,15 +202,25 @@ export function CustomerTruckAssignmentCard({
{!t.arrivedAt && (
- removeMutation.mutate(t.id)}
- loading={removeMutation.isPending}
- >
-
-
+
+ startEdit(t)}
+ >
+
+
+ removeMutation.mutate(t.id)}
+ loading={removeMutation.isPending}
+ >
+
+
+
)}
))
@@ -207,10 +232,10 @@ export function CustomerTruckAssignmentCard({
)}
- {/* Add-truck form. Export needs unassigned containers; import always allows another truck. */}
- {(isExport ? availableContainers.length > 0 : true) ? (
+ {/* Add-truck form — both directions assign the containers each truck carries. */}
+ {availableContainers.length > 0 ? (
<>
-
+
setTruckType(value ?? "")}
/>
- {isExport && (
-
- )}
+
+ {editingId && (
+
+ Cancel
+
+ )}
}
+ leftSection={editingId ? : }
color="edr-green"
onClick={submitAdd}
loading={addMutation.isPending}
>
- Add truck
+ {editingId ? "Save changes" : "Add truck"}
>
diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/MileSummaryCard.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/MileSummaryCard.tsx
new file mode 100644
index 000000000..bfb57529f
--- /dev/null
+++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/MileSummaryCard.tsx
@@ -0,0 +1,214 @@
+import { Box, Group, Stack, Text } from "@mantine/core";
+import { useQuery } from "@tanstack/react-query";
+
+import type { Freight } from "@edr/types";
+
+import { bookingsService } from "@/services/bookings.service";
+import type {
+ MileLegSummary,
+ MileVehicleSummary,
+} from "@/services/bookings.service";
+
+import { CardTitle, SectionCard } from "./layout";
+
+function StatusPill({ status }: { status: string }) {
+ const s = status.toUpperCase();
+ const done = s.includes("DELIVER") || s.includes("COMPLET") || s.includes("PAID");
+ const active = s.includes("TRANSIT") || s.includes("PROGRESS") || s.includes("ASSIGN");
+ const dot = done ? "#0EA371" : active ? "#2563EB" : "#94A3B8";
+ const color = done ? "#0A6F4D" : active ? "#1E40AF" : "#475569";
+ const bg = done ? "#ECF6F1" : active ? "#EAF1FE" : "#F1F4F7";
+ const border = done ? "#CDEBDD" : active ? "#CFDDFB" : "#E1E7EE";
+ const label = status
+ .replace(/_/g, " ")
+ .toLowerCase()
+ .replace(/\b\w/g, (m) => m.toUpperCase());
+
+ return (
+
+
+ {label}
+
+ );
+}
+
+function VehicleRow({ v }: { v: MileVehicleSummary }) {
+ const parts: string[] = [];
+ if (v.driverName) parts.push(v.driverName);
+ if (v.containerNumber) parts.push(`Container ${v.containerNumber}`);
+ if (v.distanceKm != null) parts.push(`${v.distanceKm} km`);
+
+ return (
+
+
+
+ {v.plate || v.code || "Vehicle"}
+
+ {parts.length > 0 && (
+
+ {parts.join(" · ")}
+
+ )}
+
+ {v.code && v.plate && (
+
+ {v.code}
+
+ )}
+
+ );
+}
+
+function LegBlock({
+ title,
+ leg,
+ address,
+}: {
+ title: string;
+ leg: MileLegSummary | null;
+ address?: string | null;
+}) {
+ const fmtMoney = (n: number | null, currency: string) =>
+ n == null
+ ? null
+ : `${currency} ${Number(n).toLocaleString(undefined, {
+ minimumFractionDigits: 2,
+ maximumFractionDigits: 2,
+ })}`;
+
+ return (
+
+
+
+ {title}
+
+ {leg ? (
+
+ ) : (
+
+ )}
+
+
+ {address && (
+
+ {title.startsWith("First") ? "Pickup" : "Delivery"}:{" "}
+ {address}
+
+ )}
+
+ {!leg ? (
+
+ Requested — a vehicle and driver will be assigned soon.
+
+ ) : leg.vehicles.length > 0 ? (
+
+ {leg.vehicles.map((v, i) => (
+
+ ))}
+
+ ) : (
+
+ No vehicle assigned yet.
+
+ )}
+
+ {leg && (
+
+
+ {leg.exactKm != null && (
+
+ Total distance{" "}
+ {leg.exactKm} km
+
+ )}
+ {leg.remainingPayment != null && leg.remainingPayment > 0 && (
+
+ Balance{" "}
+
+ {fmtMoney(leg.remainingPayment, leg.currency)}
+
+
+ )}
+
+ {leg.invoiced && (
+
+ Invoiced
+
+ )}
+
+ )}
+
+ );
+}
+
+export function MileSummaryCard({ booking }: { booking: Freight.IBooking }) {
+ const { data } = useQuery({
+ queryKey: ["booking-mile-summary", booking.id],
+ queryFn: () => bookingsService.mileSummary(booking.id),
+ });
+
+ const firstLeg = data?.firstMile ?? null;
+ const lastLeg = data?.lastMile ?? null;
+
+ // The backend doesn't persist an "enabled" flag — the presence of a
+ // pickup/delivery address is the request signal. Also show a leg once its
+ // record exists, regardless of address.
+ const showFirst = !!booking.firstMilePickupAddress || !!firstLeg;
+ const showLast = !!booking.lastMileDeliveryAddress || !!lastLeg;
+
+ if (!showFirst && !showLast) return null;
+
+ return (
+
+
+ First & Last Mile
+
+
+ {showFirst && (
+
+ )}
+ {showLast && (
+
+ )}
+
+
+ );
+}
diff --git a/apps/edr-freight-web/portal/src/pages/bookings/delivery/ApproveDeliveryButton.tsx b/apps/edr-freight-web/portal/src/pages/bookings/delivery/ApproveDeliveryButton.tsx
index e9ee31722..c3a0a8ccd 100644
--- a/apps/edr-freight-web/portal/src/pages/bookings/delivery/ApproveDeliveryButton.tsx
+++ b/apps/edr-freight-web/portal/src/pages/bookings/delivery/ApproveDeliveryButton.tsx
@@ -1,11 +1,8 @@
import { Button, type ButtonProps } from "@mantine/core";
-import { useMutation, useQueryClient } from "@tanstack/react-query";
import { CheckCircle2 } from "lucide-react";
-import type { MouseEvent } from "react";
-import toast from "react-hot-toast";
-import { useNavigate } from "react-router-dom";
+import { type MouseEvent, useState } from "react";
-import { api } from "@/services/api";
+import { ApproveDeliveryModal } from "./ApproveDeliveryModal";
type ApproveDeliveryButtonProps = ButtonProps & {
bookingId: string;
@@ -13,25 +10,6 @@ type ApproveDeliveryButtonProps = ButtonProps & {
onApproved?: () => void;
};
-const errorMessage = (error: unknown) => {
- 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 : "Could not approve delivery";
-};
-
-const downloadBlob = (blob: Blob, filename: string) => {
- const url = URL.createObjectURL(blob);
- const link = document.createElement("a");
- link.href = url;
- link.download = filename;
- document.body.appendChild(link);
- link.click();
- link.remove();
- URL.revokeObjectURL(url);
-};
-
export function ApproveDeliveryButton({
bookingId,
stopPropagation,
@@ -40,56 +18,31 @@ export function ApproveDeliveryButton({
variant = "filled",
...props
}: ApproveDeliveryButtonProps) {
- const navigate = useNavigate();
- const queryClient = useQueryClient();
-
- const handoverMutation = useMutation(api.bookings.downloadHandoverDocument.mutationOptions());
-
- const mutation = useMutation({
- ...api.bookings.approveDelivery.mutationOptions(),
- onSuccess: async (result) => {
- try {
- const blob = await handoverMutation.mutateAsync({ inventoryId: result.inventoryId });
- downloadBlob(blob, `handover-${bookingId}.pdf`);
- toast.success("Delivery approved and signed handover downloaded");
- } catch {
- toast.success("Delivery approved and handover signed");
- toast.error("Signed handover document could not be downloaded");
- }
- await Promise.all([
- queryClient.invalidateQueries({ queryKey: api.bookings.get.queryKey({ id: bookingId }) }),
- queryClient.invalidateQueries({ queryKey: api.bookings.list.queryKey() }),
- queryClient.invalidateQueries({ queryKey: ["companies", "getDashboard"] }),
- ]);
- onApproved?.();
- },
- onError: (error) => {
- const message = errorMessage(error);
- toast.error(message);
- if (message.toLowerCase().includes("save your signature")) {
- navigate("/signature");
- } else if (/demurrage|storage|warehouse invoice|fully paid/i.test(message)) {
- navigate("/billing");
- }
- },
- });
+ const [opened, setOpened] = useState(false);
const handleClick = (event: MouseEvent) => {
if (stopPropagation) event.stopPropagation();
- mutation.mutate({ id: bookingId });
+ setOpened(true);
};
return (
- }
- loading={mutation.isPending || handoverMutation.isPending}
- onClick={handleClick}
- >
- Approve delivery
-
+ <>
+ }
+ onClick={handleClick}
+ >
+ Approve delivery
+
+ setOpened(false)}
+ onApproved={onApproved}
+ />
+ >
);
}
diff --git a/apps/edr-freight-web/portal/src/pages/bookings/delivery/ApproveDeliveryModal.tsx b/apps/edr-freight-web/portal/src/pages/bookings/delivery/ApproveDeliveryModal.tsx
new file mode 100644
index 000000000..a3723b650
--- /dev/null
+++ b/apps/edr-freight-web/portal/src/pages/bookings/delivery/ApproveDeliveryModal.tsx
@@ -0,0 +1,171 @@
+import { Alert, Button, Group, Loader, Modal, Stack, Text } from "@mantine/core";
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import { CheckCircle2, Info } from "lucide-react";
+import { useEffect, useState } from "react";
+import toast from "react-hot-toast";
+import { useNavigate } from "react-router-dom";
+
+import { api } from "@/services/api";
+import { bookingsService } from "@/services/bookings.service";
+
+type ApproveDeliveryModalProps = {
+ bookingId: string;
+ opened: boolean;
+ onClose: () => void;
+ onApproved?: () => void;
+};
+
+const errorMessage = (error: unknown) => {
+ 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 : "Could not approve delivery";
+};
+
+const downloadBlob = (blob: Blob, filename: string) => {
+ const url = URL.createObjectURL(blob);
+ const link = document.createElement("a");
+ link.href = url;
+ link.download = filename;
+ document.body.appendChild(link);
+ link.click();
+ link.remove();
+ URL.revokeObjectURL(url);
+};
+
+/**
+ * Approve-delivery flow: open the handover document for the customer to review,
+ * then apply their saved signature (approve) and hand back the signed PDF.
+ */
+export function ApproveDeliveryModal({
+ bookingId,
+ opened,
+ onClose,
+ onApproved,
+}: ApproveDeliveryModalProps) {
+ const navigate = useNavigate();
+ const queryClient = useQueryClient();
+ const [pdfUrl, setPdfUrl] = useState(null);
+
+ const {
+ data: docBlob,
+ isLoading,
+ isError,
+ } = useQuery({
+ queryKey: ["booking-handover-doc", bookingId],
+ queryFn: () => bookingsService.downloadBookingHandoverDocument(bookingId),
+ enabled: opened && Boolean(bookingId),
+ staleTime: 0,
+ });
+
+ useEffect(() => {
+ if (!docBlob) {
+ setPdfUrl(null);
+ return;
+ }
+ const url = URL.createObjectURL(docBlob);
+ setPdfUrl(url);
+ return () => URL.revokeObjectURL(url);
+ }, [docBlob]);
+
+ const handoverMutation = useMutation(
+ api.bookings.downloadHandoverDocument.mutationOptions(),
+ );
+
+ const approve = useMutation({
+ ...api.bookings.approveDelivery.mutationOptions(),
+ onSuccess: async (result) => {
+ try {
+ const signed = await handoverMutation.mutateAsync({
+ inventoryId: result.inventoryId,
+ });
+ downloadBlob(signed, `handover-${bookingId}.pdf`);
+ toast.success("Delivery approved and signed handover downloaded");
+ } catch {
+ toast.success("Delivery approved and handover signed");
+ toast.error("Signed handover document could not be downloaded");
+ }
+ await Promise.all([
+ queryClient.invalidateQueries({
+ queryKey: api.bookings.get.queryKey({ id: bookingId }),
+ }),
+ queryClient.invalidateQueries({ queryKey: api.bookings.list.queryKey() }),
+ queryClient.invalidateQueries({ queryKey: ["companies", "getDashboard"] }),
+ ]);
+ onApproved?.();
+ onClose();
+ },
+ onError: (error) => {
+ const message = errorMessage(error);
+ toast.error(message);
+ if (message.toLowerCase().includes("save your signature")) {
+ onClose();
+ navigate("/signature");
+ } else if (/demurrage|storage|warehouse invoice|fully paid/i.test(message)) {
+ onClose();
+ navigate("/billing");
+ }
+ },
+ });
+
+ const busy = approve.isPending || handoverMutation.isPending;
+
+ return (
+
+
+ }>
+
+ Review the handover document below. Approving applies your saved signature
+ and confirms you received the goods.
+
+
+
+ {isLoading ? (
+
+
+
+ Loading handover document…
+
+
+ ) : isError || !pdfUrl ? (
+
+ Could not load the handover document. It may not be generated yet.
+
+ ) : (
+
+ )}
+
+
+
+ Cancel
+
+ }
+ loading={busy}
+ disabled={isLoading || isError}
+ onClick={() => approve.mutate({ id: bookingId })}
+ >
+ Approve & sign delivery
+
+
+
+
+ );
+}
diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step-documents.tsx b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step-documents.tsx
index e0ae67384..880efb846 100644
--- a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step-documents.tsx
+++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step-documents.tsx
@@ -87,7 +87,7 @@ export function StepDocuments({ form }: { form: BookingForm }) {
{onboardingDocs.map((doc, i) => (
navigate("/contracts")}
+ />
+ );
+ }
+
const [pricingData, setPricingData] =
useState(null);
// In edit mode the contract already exists, so seed its id — this makes
@@ -191,14 +203,18 @@ export default function NewContractPage({
contractId = contract.id;
}
- const pricing = await api.contracts.generatePrice.call({ id: contractId });
+ const pricing = await api.contracts.generatePrice.call({
+ id: contractId,
+ });
return { contractId, pricing, mode };
},
onSuccess: ({ contractId, pricing, mode }) => {
setPriceContractId(contractId);
setPricingData(pricing);
setPriceModalMode(mode);
- queryClient.invalidateQueries({ queryKey: api.contracts.list.queryKey() });
+ queryClient.invalidateQueries({
+ queryKey: api.contracts.list.queryKey(),
+ });
},
});
@@ -214,7 +230,9 @@ export default function NewContractPage({
}
clearContractDraft();
setPriceModalMode(null);
- queryClient.invalidateQueries({ queryKey: api.contracts.list.queryKey() });
+ queryClient.invalidateQueries({
+ queryKey: api.contracts.list.queryKey(),
+ });
navigate("/contracts");
},
});
@@ -228,7 +246,9 @@ export default function NewContractPage({
clearContractDraft();
setPriceChangeResult(null);
setPriceModalMode(null);
- queryClient.invalidateQueries({ queryKey: api.contracts.list.queryKey() });
+ queryClient.invalidateQueries({
+ queryKey: api.contracts.list.queryKey(),
+ });
navigate("/contracts");
},
});
@@ -242,7 +262,9 @@ export default function NewContractPage({
clearContractDraft();
setPriceModalMode(null);
setPriceContractId(null);
- queryClient.invalidateQueries({ queryKey: api.contracts.list.queryKey() });
+ queryClient.invalidateQueries({
+ queryKey: api.contracts.list.queryKey(),
+ });
navigate("/contracts");
},
});
@@ -325,6 +347,17 @@ export default function NewContractPage({
m.set(p.type, p.status);
return m;
}, [auth.company]);
+ // Full profile per type, so the awaiting/rejected modal can show the reviewer
+ // note and offer a reapply for a rejected role.
+ const profileByType = useMemo(() => {
+ const m = new Map<
+ string,
+ { id: string; status: string; reviewNote?: string | null }
+ >();
+ for (const p of auth.company?.company?.companyProfiles ?? [])
+ m.set(p.type, { id: p.id, status: p.status, reviewNote: p.reviewNote });
+ return m;
+ }, [auth.company]);
const profileTypes = useMemo(
() => [...profileStatusByType.keys()],
[profileStatusByType],
@@ -343,12 +376,14 @@ export default function NewContractPage({
// badges. Intercity rides any customer profile, so always "approved".
const operationStatus = useMemo(
() =>
- (op: OperationType): "approved" | "pending" | "missing" => {
+ (op: OperationType): "approved" | "pending" | "rejected" | "missing" => {
if (op === "intercity") return "approved";
const target = operationToProfileType(op, profileTypes);
const status = profileStatusByType.get(target);
if (!status) return "missing";
- return status === "active" ? "approved" : "pending";
+ if (status === "active") return "approved";
+ if (status === "rejected") return "rejected";
+ return "pending";
},
[profileStatusByType, profileTypes],
);
@@ -398,6 +433,17 @@ export default function NewContractPage({
},
});
+ // Resubmit a rejected operational role for approval (from the block modal).
+ const reapplyMutation = useMutation({
+ mutationFn: async (profileId: string) => {
+ const res = await auth.reapplyProfile(profileId);
+ if (!res.success) {
+ throw new Error(res.error?.message ?? "Failed to resubmit for approval");
+ }
+ },
+ onSuccess: () => setPendingApprovalProfile(null),
+ });
+
const handleOperationSelect = (op: OperationType) => {
// Intercity (domestic) runs on any existing customer profile — no switch.
if (op === "intercity") return;
@@ -551,23 +597,23 @@ export default function NewContractPage({
: {}),
...(serviceType?.includesFirstMile && data.firstMile.enabled
? {
- firstMilePickupAddress: data.firstMile.pickUpAddress,
- firstMilePickupLat: data.firstMile.lat ?? undefined,
- firstMilePickupLng: data.firstMile.lng ?? undefined,
- }
+ firstMilePickupAddress: data.firstMile.pickUpAddress,
+ firstMilePickupLat: data.firstMile.lat ?? undefined,
+ firstMilePickupLng: data.firstMile.lng ?? undefined,
+ }
: {}),
...(serviceType?.includesLastMile && data.lastMile.enabled
? {
- lastMileDeliveryAddress: data.lastMile.deliveryAddress,
- lastMileDeliveryLat: data.lastMile.lat ?? undefined,
- lastMileDeliveryLng: data.lastMile.lng ?? undefined,
- }
+ lastMileDeliveryAddress: data.lastMile.deliveryAddress,
+ lastMileDeliveryLat: data.lastMile.lat ?? undefined,
+ lastMileDeliveryLng: data.lastMile.lng ?? undefined,
+ }
: {}),
...(serviceType?.includesCustoms && data.customsClearingEnabled
? {
- customsClearingEnabled: true,
- customsClearingAgent: data.customsClearingAgent || undefined,
- }
+ customsClearingEnabled: true,
+ customsClearingAgent: data.customsClearingAgent || undefined,
+ }
: { customsClearingEnabled: false }),
cargoScope,
routes,
@@ -652,7 +698,12 @@ export default function NewContractPage({
mb="lg"
>
-
+
{isEdit ? "Edit Contract" : "New Contract"}
@@ -791,12 +842,12 @@ export default function NewContractPage({
errors={
showDocErrors
? Object.fromEntries(
- missingRequiredDocKeys(
- editDocSettingQuery.data,
- editContract,
- editDocuments,
- ).map((k) => [k, "Required"]),
- )
+ missingRequiredDocKeys(
+ editDocSettingQuery.data,
+ editContract,
+ editDocuments,
+ ).map((k) => [k, "Required"]),
+ )
: {}
}
/>
@@ -814,9 +865,9 @@ export default function NewContractPage({
pricing={
pricingData
? {
- currency: pricingData.currency,
- lineItems: pricingData.lineItems,
- }
+ currency: pricingData.currency,
+ lineItems: pricingData.lineItems,
+ }
: null
}
onSaveDraft={handleSaveDraft}
@@ -926,8 +977,8 @@ export default function NewContractPage({
Pricing schedule
- Final amount is calculated at booking — quantities are unknown at
- the contract stage.
+ Final amount is calculated at booking — quantities are unknown
+ at the contract stage.
{pricingData.lineItems.map((item) => (
@@ -1087,7 +1138,7 @@ export default function NewContractPage({
loading={confirmSubmitMutation.isPending}
onClick={() => confirmSubmitMutation.mutate()}
>
- Confirm & submit
+ Confirm {"&"} submit
@@ -1131,9 +1182,9 @@ export default function NewContractPage({
- License submitted. Your {createTargetLabel.toLowerCase()} profile
- is now awaiting staff approval. We'll notify you once it's
- approved — then you can create this contract as{" "}
+ License submitted. Your {createTargetLabel.toLowerCase()}{" "}
+ profile is now awaiting staff approval. We'll notify you once
+ it's approved — then you can create this contract as{" "}
{createTargetLabel.toLowerCase()}.
@@ -1146,9 +1197,9 @@ export default function NewContractPage({
) : (
- You don't have a {createTargetLabel.toLowerCase()} profile yet. Add
- your business license to create one. It goes to staff for approval
- before you can use it.
+ You don't have a {createTargetLabel.toLowerCase()} profile yet.
+ Add your business license to create one. It goes to staff for
+ approval before you can use it.
- {/* Awaiting-approval modal — the chosen operation maps to a profile that
- exists but isn't approved yet. The select was already reverted. */}
- setPendingApprovalProfile(null)}
- title="Awaiting approval"
- centered
- radius="lg"
- >
-
-
- Your{" "}
- {pendingApprovalProfile
- ? (PROFILE_TYPE_LABELS[pendingApprovalProfile] ??
- pendingApprovalProfile)
- : ""}{" "}
- profile was submitted and is under staff review. You can start a
- contract under it once it's approved.
-
-
- setPendingApprovalProfile(null)}
- >
- OK
-
-
-
-
+ {/* Awaiting-approval / rejected modal — the chosen operation maps to a
+ profile that exists but isn't active. The select was already reverted. */}
+ {(() => {
+ const target = pendingApprovalProfile
+ ? profileByType.get(pendingApprovalProfile)
+ : undefined;
+ const isRejected = target?.status === "rejected";
+ const label = pendingApprovalProfile
+ ? (PROFILE_TYPE_LABELS[pendingApprovalProfile] ??
+ pendingApprovalProfile)
+ : "";
+ return (
+ setPendingApprovalProfile(null)}
+ title={isRejected ? "Profile not approved" : "Awaiting approval"}
+ centered
+ radius="lg"
+ >
+
+ {isRejected ? (
+ <>
+
+ Your {label} profile was not approved. Fix the issue below
+ and resubmit it for review.
+
+ {target?.reviewNote && (
+
+
+ Reviewer note: {target.reviewNote}
+
+
+ )}
+
+ setPendingApprovalProfile(null)}
+ disabled={reapplyMutation.isPending}
+ >
+ Close
+
+
+ target && reapplyMutation.mutate(target.id)
+ }
+ >
+ Resubmit for approval
+
+
+ >
+ ) : (
+ <>
+
+ Your {label} profile was submitted and is under staff
+ review. You can start a contract under it once it's approved.
+
+
+ setPendingApprovalProfile(null)}
+ >
+ OK
+
+
+ >
+ )}
+
+
+ );
+ })()}
);
}
diff --git a/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx b/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx
index 77086103b..2f0681590 100644
--- a/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx
+++ b/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx
@@ -1151,9 +1151,14 @@ function ContainerLineEditor({
render={({ field, fieldState }) => (
field.onChange(e.currentTarget.value.toUpperCase())
}
+=======
+ onChange={(e) => field.onChange(e.currentTarget.value.toUpperCase())}
+ maxLength={11}
+>>>>>>> 1b9ac42ed3fc9bd6b8dea8d08b5c4c066d083feb
label={u === 0 ? "Container number *" : undefined}
placeholder="e.g. MSCU1234567"
error={fieldState.error?.message}
diff --git a/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step-documents.tsx b/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step-documents.tsx
index 34f8d92ea..86b6f61a2 100644
--- a/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step-documents.tsx
+++ b/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step-documents.tsx
@@ -156,7 +156,7 @@ export function StepDocuments({
{onboardingDocs.map((doc, i) => (
void;
/** Approval state of the profile each operation maps to (for the badges). */
- operationStatus?: (op: OperationType) => "approved" | "pending" | "missing";
+ operationStatus?: (
+ op: OperationType,
+ ) => "approved" | "pending" | "rejected" | "missing";
}) {
const contractType = form.watch("contractType");
@@ -221,6 +223,11 @@ export function Step1ContractType({
Pending
)}
+ {status === "rejected" && (
+
+ Rejected
+
+ )}
{status === "missing" && (
Add license
diff --git a/apps/edr-freight-web/portal/src/pages/contracts/new-shipment-form/schema.ts b/apps/edr-freight-web/portal/src/pages/contracts/new-shipment-form/schema.ts
index a21a69a76..245f10475 100644
--- a/apps/edr-freight-web/portal/src/pages/contracts/new-shipment-form/schema.ts
+++ b/apps/edr-freight-web/portal/src/pages/contracts/new-shipment-form/schema.ts
@@ -26,16 +26,31 @@ export interface ShipmentValidationContext {
requiresDate?: boolean;
}
+<<<<<<< HEAD
// ISO 6346: 3-letter owner code + category id (U/J/Z) + 6-digit serial + check digit.
const ISO_CONTAINER_NUMBER_REGEX = /^[A-Z]{4}\d{7}$/;
+=======
+// ISO 6346: 4 letters (owner + category) + 6 serial digits + 1 check digit.
+// Enforced at booking input so the number stays a clean reference in the warehouse.
+const ISO_CONTAINER_RE = /^[A-Z]{4}\d{7}$/;
+>>>>>>> 1b9ac42ed3fc9bd6b8dea8d08b5c4c066d083feb
const containerUnitSchema = z.object({
containerNumber: z
.string()
+<<<<<<< HEAD
.min(1, "Container number is required.")
.refine(
(v) => ISO_CONTAINER_NUMBER_REGEX.test(v.trim().toUpperCase()),
"Enter a valid ISO container number (e.g. ABCD1234567).",
+=======
+ .transform((v) => v.trim().toUpperCase())
+ .pipe(
+ z
+ .string()
+ .min(1, "Container number is required.")
+ .regex(ISO_CONTAINER_RE, "Use ISO format: 4 letters + 7 digits, e.g. ABCU1234567."),
+>>>>>>> 1b9ac42ed3fc9bd6b8dea8d08b5c4c066d083feb
),
sealNumber: z.string().default(""),
vgmTons: z
diff --git a/apps/edr-freight-web/portal/src/pages/settings/TabCompanyProfile.tsx b/apps/edr-freight-web/portal/src/pages/settings/TabCompanyProfile.tsx
index 995acc104..27011a9d7 100644
--- a/apps/edr-freight-web/portal/src/pages/settings/TabCompanyProfile.tsx
+++ b/apps/edr-freight-web/portal/src/pages/settings/TabCompanyProfile.tsx
@@ -34,6 +34,12 @@ export const COMPANY_PROFILE_SCHEMA = z.object({
companyAddress: z.string().min(1, "Address is required"),
tinNumber: z.string().length(10, "TIN must be exactly 10 digits"),
fanNumber: z.string().length(16, "FAN must be exactly 16 digits"),
+ vatNumber: z
+ .string()
+ .trim()
+ .max(20, "VAT number is too long")
+ .optional()
+ .or(z.literal("")),
});
export type CompanyProfileFormData = z.infer;
@@ -63,6 +69,7 @@ export default function TabCompanyProfile({
companyAddress: profile.companyAddress ?? "",
tinNumber: profile.tinNumber,
fanNumber: profile.fanNumber ?? "",
+ vatNumber: profile.vatNumber ?? "",
};
}
return {
@@ -73,6 +80,7 @@ export default function TabCompanyProfile({
companyAddress: "",
tinNumber: "",
fanNumber: "",
+ vatNumber: "",
};
}, [profile]);
@@ -97,6 +105,7 @@ export default function TabCompanyProfile({
companyAddress: data.companyAddress,
tin: data.tinNumber,
fanNumber: data.fanNumber,
+ vatNumber: data.vatNumber ?? "",
};
if (isCreate) {
@@ -223,6 +232,18 @@ export default function TabCompanyProfile({
/>
+
+
+
+
+
+
= {
importer: "Importer",
@@ -137,9 +152,7 @@ export default function TabDocuments({
return errs;
};
- const licenseProfiles = profile.companyProfiles.filter(
- (p) => p.licenseFiles && p.licenseFiles.length > 0,
- );
+ const licenseProfiles = profile.companyProfiles;
return (
<>
@@ -246,24 +259,19 @@ export default function TabDocuments({
Business licenses
- License documents uploaded per operational profile
+ Add, replace or remove the license documents for each operational
+ profile. Changes are submitted to EDR for review before they take
+ effect.
-
+
{licenseProfiles.map((p) => (
-
-
- {ROLE_LABELS[p.type] ?? p.type} · {p.reference}
-
- {p.licenseFiles.map((f) => (
-
-
-
- {f.name}
-
-
- ))}
-
+
))}
@@ -273,3 +281,263 @@ export default function TabDocuments({
>
);
}
+
+const LICENSE_ACCEPT = ".pdf,.png,.jpg,.jpeg";
+
+function formatBytes(bytes: number): string {
+ if (!bytes) return "";
+ const units = ["B", "KB", "MB", "GB"];
+ const i = Math.floor(Math.log(bytes) / Math.log(1024));
+ return `${parseFloat((bytes / Math.pow(1024, i)).toFixed(1))} ${units[i]}`;
+}
+
+const STATUS_BADGE: Record<
+ LicenseFileStatus,
+ { label: string; color: string; bg: string; fg: string } | null
+> = {
+ live: null,
+ pending_add: {
+ label: "Pending approval",
+ color: "edr-amber",
+ bg: "var(--mantine-color-edr-amber-soft-0)",
+ fg: "var(--mantine-color-edr-amber-text-0)",
+ },
+ pending_remove: {
+ label: "Removal pending",
+ color: "edr-red",
+ bg: "var(--mantine-color-edr-red-soft-0)",
+ fg: "var(--mantine-color-edr-red-0)",
+ },
+};
+
+/**
+ * One operational profile's business-license documents. Lists each file (click
+ * to preview via the file proxy) with its review state, and lets the customer
+ * add / replace / remove files. Every mutation opens a change request the
+ * backoffice must approve; while one is open the parent locks this whole tab.
+ */
+function ProfileLicenseRow({
+ profile,
+ onViewFile,
+ reviewPending,
+}: {
+ profile: CompanyProfileResponse;
+ onViewFile: (file: ViewableFile) => void;
+ reviewPending: boolean;
+}) {
+ const queryClient = useQueryClient();
+ const addInputRef = useRef(null);
+ const replaceInputRef = useRef(null);
+ const replaceTargetId = useRef(null);
+ const [error, setError] = useState(null);
+
+ const invalidate = () => {
+ setError(null);
+ queryClient.invalidateQueries({
+ queryKey: api.companies.getProfile.queryKey(),
+ });
+ };
+
+ const addMutation = useMutation({
+ mutationFn: (files: File[]) =>
+ companiesService.uploadProfileLicense(profile.id, files),
+ onSuccess: invalidate,
+ onError: () => setError("Upload failed. Please try again."),
+ });
+ const replaceMutation = useMutation({
+ mutationFn: ({ fileId, file }: { fileId: string; file: File }) =>
+ companiesService.replaceProfileLicense(profile.id, fileId, file),
+ onSuccess: invalidate,
+ onError: () => setError("Replace failed. Please try again."),
+ });
+ const removeMutation = useMutation({
+ mutationFn: (fileId: string) =>
+ companiesService.removeProfileLicense(profile.id, fileId),
+ onSuccess: invalidate,
+ onError: () => setError("Remove failed. Please try again."),
+ });
+
+ const busy =
+ addMutation.isPending ||
+ replaceMutation.isPending ||
+ removeMutation.isPending;
+ const files = profile.licenseFiles ?? [];
+
+ return (
+
+
+
+ {ROLE_LABELS[profile.type] ?? profile.type} · {profile.reference}
+
+ }
+ loading={addMutation.isPending}
+ disabled={busy}
+ onClick={() => addInputRef.current?.click()}
+ >
+ Add document
+
+
+
+ {files.length === 0 ? (
+
+
+ No license documents yet.
+
+
+ ) : (
+
+ {files.map((f) => {
+ const badge = STATUS_BADGE[f.status];
+ const isPending = f.status !== "live";
+ return (
+
+
+
+
+
+ onViewFile({
+ name: f.name,
+ url: fileViewUrl(f.id),
+ mimeType: f.mimeType,
+ })
+ }
+ style={{
+ textAlign: "left",
+ textDecoration:
+ f.status === "pending_remove"
+ ? "line-through"
+ : undefined,
+ }}
+ lineClamp={1}
+ >
+ {f.name}
+
+ {f.size > 0 && (
+
+ {formatBytes(f.size)}
+
+ )}
+
+
+ {badge && (
+ }
+ style={{
+ backgroundColor: badge.bg,
+ color: badge.fg,
+ flexShrink: 0,
+ }}
+ >
+ {badge.label}
+
+ )}
+
+
+ {
+ replaceTargetId.current = f.id;
+ replaceInputRef.current?.click();
+ }}
+ >
+
+
+
+
+ removeMutation.mutate(f.id)}
+ >
+
+
+
+
+
+ );
+ })}
+
+ )}
+
+ {reviewPending && (
+
+
+
+ Awaiting EDR review — further changes are disabled until it clears.
+
+
+ )}
+ {error && (
+
+
+
+ {error}
+
+
+ )}
+
+ {
+ const picked = e.target.files ? Array.from(e.target.files) : [];
+ if (picked.length > 0) addMutation.mutate(picked);
+ e.target.value = "";
+ }}
+ />
+ {
+ const file = e.target.files?.[0];
+ const fileId = replaceTargetId.current;
+ if (file && fileId) replaceMutation.mutate({ fileId, file });
+ replaceTargetId.current = null;
+ e.target.value = "";
+ }}
+ />
+
+ );
+}
diff --git a/apps/edr-freight-web/portal/src/services/api.ts b/apps/edr-freight-web/portal/src/services/api.ts
index 932f6fc46..bad712689 100644
--- a/apps/edr-freight-web/portal/src/services/api.ts
+++ b/apps/edr-freight-web/portal/src/services/api.ts
@@ -54,6 +54,7 @@ import {
UpdateDropdownSettingDto,
} from "@/types/dropdownSettings";
import type {
+ ChangeRequestResponse,
CompanyDocument,
CompanyInfoResponse,
CompanyNationality,
@@ -211,6 +212,18 @@ export const api = {
"documents",
({ companyId }) => companiesService.getDocuments(companyId),
),
+
+ changeRequest: endpoint(
+ "companies",
+ "changeRequest",
+ companiesService.getChangeRequest,
+ ),
+
+ reapplyProfile: endpoint<{ profileId: string }, CompanyProfileResponse>(
+ "companies",
+ "reapplyProfile",
+ ({ profileId }) => companiesService.reapplyProfile(profileId),
+ ),
},
bookings: {
@@ -251,6 +264,13 @@ export const api = {
bookingsService.downloadHandoverDocument(inventoryId),
),
+ downloadBookingHandoverDocument: endpoint<{ bookingId: string }, Blob>(
+ "bookings",
+ "downloadBookingHandoverDocument",
+ ({ bookingId }) =>
+ bookingsService.downloadBookingHandoverDocument(bookingId),
+ ),
+
create: endpoint<
{ payload: CreateBookingPayload; documents?: BookingDocuments },
Freight.IBooking
diff --git a/apps/edr-freight-web/portal/src/services/bookings.service.ts b/apps/edr-freight-web/portal/src/services/bookings.service.ts
index c2729bebb..852452099 100644
--- a/apps/edr-freight-web/portal/src/services/bookings.service.ts
+++ b/apps/edr-freight-web/portal/src/services/bookings.service.ts
@@ -7,6 +7,26 @@ import { client } from "../utils/api";
const B = URL_CONSTANTS.BOOKINGS;
+export interface MileVehicleSummary {
+ plate: string | null;
+ code: string | null;
+ driverName: string | null;
+ containerNumber: string | null;
+ distanceKm: number | null;
+}
+export interface MileLegSummary {
+ status: string;
+ exactKm: number | null;
+ remainingPayment: number | null;
+ currency: string;
+ invoiced: boolean;
+ vehicles: MileVehicleSummary[];
+}
+export interface MileSummaryResponse {
+ firstMile: MileLegSummary | null;
+ lastMile: MileLegSummary | null;
+}
+
export type CreateBookingPayload = Freight.CreateBookingDto;
export interface ContractView {
@@ -150,6 +170,10 @@ export const bookingsService = {
const { data } = await client.get(`/api/bookings/${id}`);
return data.data;
},
+ mileSummary: async (id: string): Promise => {
+ const { data } = await client.get(`/api/bookings/${id}/mile-summary`);
+ return data.data;
+ },
assignCustomerTruck: async (
id: string,
payload: CustomerTruckAssignmentPayload,
@@ -174,6 +198,13 @@ export const bookingsService = {
);
return data;
},
+ downloadBookingHandoverDocument: async (bookingId: string): Promise => {
+ const { data } = await client.get(
+ `/api/warehouse-inventory/bookings/${bookingId}/handover-document`,
+ { responseType: "blob" },
+ );
+ return data;
+ },
tracking: async (id: string): Promise => {
const { data } = await client.get(`/api/bookings/${id}/tracking`);
return data.data;
diff --git a/apps/edr-freight-web/portal/src/services/companies.service.ts b/apps/edr-freight-web/portal/src/services/companies.service.ts
index d3f584170..ad93e03a8 100644
--- a/apps/edr-freight-web/portal/src/services/companies.service.ts
+++ b/apps/edr-freight-web/portal/src/services/companies.service.ts
@@ -14,11 +14,16 @@ export type ProfileTypeValue =
export type CompanyNationality = "ethiopian" | "foreign";
+/** Review state of a business-license file (mirrors the API's ProfileLicenseFileView). */
+export type LicenseFileStatus = "live" | "pending_add" | "pending_remove";
+
export interface LicenseFile {
+ id: string;
name: string;
- url: string;
size: number;
- mimeType?: string;
+ mimeType: string;
+ /** `live` = approved; `pending_add`/`pending_remove` = awaiting backoffice review. */
+ status: LicenseFileStatus;
}
export interface ExternalProfileResponse {
@@ -73,6 +78,8 @@ export interface CompanyProfileResponse {
/** Business-license documents uploaded for this profile. */
licenseFiles: LicenseFile[];
attributes: Record | null;
+ /** Reviewer note when the role is rejected (drives the reapply prompt). */
+ reviewNote?: string | null;
createdAt: string;
updatedAt: string;
}
@@ -80,6 +87,28 @@ export interface CompanyProfileResponse {
export interface CompanyInfoResponse {
profile: ExternalProfileResponse;
company: CompanyResponse;
+ /**
+ * Open profile-edit review, if any. `pending` locks the settings page + new
+ * contract/booking creation; `rejected` surfaces the note for reapply.
+ */
+ review?: {
+ status: "pending" | "rejected";
+ note: string | null;
+ } | null;
+}
+
+/** A staged profile-edit review request (portal view). */
+export interface ChangeRequestResponse {
+ id: string;
+ companyId: string;
+ status: "pending" | "approved" | "rejected";
+ snapshot: Record;
+ documentFileIds: string[];
+ note: string | null;
+ submittedAt: string | null;
+ reviewedAt: string | null;
+ createdAt: string;
+ updatedAt: string;
}
/** A single company-level document uploaded against a `file_upload_settings` field. */
@@ -328,7 +357,11 @@ export const companiesService = {
return unwrap(response.data);
},
- /** Upload business-license document(s) for a company profile (multi-file). */
+ /**
+ * Add business-license document(s) to a company profile. For an approved
+ * company the upload is staged for backoffice review; during onboarding it
+ * goes live immediately. Returns the profile's full license list with state.
+ */
uploadProfileLicense: async (
profileId: string,
files: File[],
@@ -343,7 +376,33 @@ export const companiesService = {
return unwrap(response.data);
},
- /** List business-license document(s) already uploaded for a company profile. */
+ /** Replace a license file with a newly uploaded one (staged for review). */
+ replaceProfileLicense: async (
+ profileId: string,
+ fileId: string,
+ file: File,
+ ): Promise => {
+ const formData = new FormData();
+ formData.append("business_license", file);
+ const response = await client.post>(
+ URL_CONSTANTS.COMPANIES_API.PROFILE_LICENSE_REPLACE(profileId, fileId),
+ formData,
+ );
+ return unwrap(response.data);
+ },
+
+ /** Remove a license file (staged for review on an approved company). */
+ removeProfileLicense: async (
+ profileId: string,
+ fileId: string,
+ ): Promise => {
+ const response = await client.delete>(
+ URL_CONSTANTS.COMPANIES_API.PROFILE_LICENSE_FILE(profileId, fileId),
+ );
+ return unwrap(response.data);
+ },
+
+ /** List business-license document(s) (with review state) for a company profile. */
getProfileLicense: async (profileId: string): Promise => {
const response = await client.get>(
URL_CONSTANTS.COMPANIES_API.PROFILE_LICENSE(profileId),
@@ -351,6 +410,24 @@ export const companiesService = {
return unwrap(response.data);
},
+ /** The current company's open profile change request (pending/rejected), or null. */
+ getChangeRequest: async (): Promise => {
+ const response = await client.get>(
+ URL_CONSTANTS.COMPANIES_API.PROFILE_CHANGE_REQUEST,
+ );
+ return unwrap(response.data);
+ },
+
+ /** Resubmit a rejected operational role for approval (→ pending). */
+ reapplyProfile: async (
+ profileId: string,
+ ): Promise => {
+ const response = await client.post>(
+ URL_CONSTANTS.COMPANIES_API.PROFILE_REAPPLY(profileId),
+ );
+ return unwrap(response.data);
+ },
+
/** Fetch company registration data from eTrade by TIN. */
fetchETradeInfo: async (payload: { tin: string }): Promise => {
const response = await client.post>(
diff --git a/apps/edr-freight-web/portal/src/services/customer-trucks.service.ts b/apps/edr-freight-web/portal/src/services/customer-trucks.service.ts
index ee317e204..2663d43ec 100644
--- a/apps/edr-freight-web/portal/src/services/customer-trucks.service.ts
+++ b/apps/edr-freight-web/portal/src/services/customer-trucks.service.ts
@@ -23,6 +23,15 @@ export const customerTrucksService = {
return data.data ?? data;
},
+ update: async (
+ bookingId: string,
+ assignmentId: string,
+ payload: Freight.AddCustomerTruckPayload,
+ ): Promise => {
+ const { data } = await client.patch(B.CUSTOMER_TRUCK(bookingId, assignmentId), payload);
+ return data.data ?? data;
+ },
+
remove: async (
bookingId: string,
assignmentId: string,
diff --git a/apps/edr-freight-web/portal/src/types/profile.ts b/apps/edr-freight-web/portal/src/types/profile.ts
index 3d3f2bad6..1e267fa66 100644
--- a/apps/edr-freight-web/portal/src/types/profile.ts
+++ b/apps/edr-freight-web/portal/src/types/profile.ts
@@ -40,6 +40,14 @@ export interface ProfileResponse {
poaLocation: string | null;
poaAddress: string | null;
profileId: string;
+ /**
+ * Open profile-edit review. `pending` → the settings page is read-only until an
+ * admin decides; `rejected` → the note explains why and the forms prefill the
+ * declined values so the customer can amend & resubmit.
+ */
+ reviewStatus?: "pending" | "rejected" | null;
+ reviewNote?: string | null;
+ pendingChanges?: Record | null;
}
export interface UpdateProfilePayload {
diff --git a/apps/edr-freight-web/portal/src/utils/api.ts b/apps/edr-freight-web/portal/src/utils/api.ts
index 289709906..6cc569477 100644
--- a/apps/edr-freight-web/portal/src/utils/api.ts
+++ b/apps/edr-freight-web/portal/src/utils/api.ts
@@ -42,21 +42,41 @@ client.interceptors.request.use((config) => {
});
// Token refresh state
-let isRefreshing = false;
-let failedQueue: {
- resolve: (token: string) => void;
- reject: (error: unknown) => void;
-}[] = [];
+let refreshPromise: Promise | null = null;
-function processQueue(error: unknown, token?: string) {
- failedQueue.forEach(({ resolve, reject }) => {
- if (error) {
- reject(error);
- } else {
- resolve(token!);
+/**
+ * Single-flight token refresh: concurrent callers (the 401 interceptor and
+ * the proactive scheduler) share one in-flight request so the refresh token
+ * is only rotated once. Throws if no refresh token is stored or the server
+ * rejects it — callers decide how to end the session.
+ */
+async function refreshSessionTokens(): Promise {
+ refreshPromise ??= (async () => {
+ const refreshToken = getCookie("refresh-token");
+ if (!refreshToken) {
+ throw new Error("missing refresh token");
}
+
+ type TokenPair = { token: string; refreshToken: string };
+ const { data } = await client.post & { data?: TokenPair }>(
+ URL_CONSTANTS.AUTH.REFRESH_TOKEN,
+ { refreshToken },
+ );
+ // The API returns the pair flat ({ success, token, refreshToken }); accept
+ // a { data: { ... } }-wrapped shape too so a transform change can't
+ // silently break refresh again.
+ const payload = data.data ?? data;
+ if (!payload.token || !payload.refreshToken) {
+ throw new Error("malformed refresh-token response");
+ }
+ setCookie("auth-token", payload.token, 7);
+ setCookie("refresh-token", payload.refreshToken, 7);
+ return payload.token;
+ })().finally(() => {
+ refreshPromise = null;
});
- failedQueue = [];
+
+ return refreshPromise;
}
// Handle auth errors globally with token refresh
@@ -72,54 +92,41 @@ client.interceptors.response.use(
// - status is not 401
// - already retried
// - it's the refresh endpoint itself
+ // - it's a credential endpoint (401 there = wrong credentials, not an
+ // expired session — refreshing would mask the real error)
if (
!error.response ||
error.response.status !== 401 ||
originalRequest._retry ||
- originalRequest.url === URL_CONSTANTS.AUTH.REFRESH_TOKEN
+ originalRequest.url === URL_CONSTANTS.AUTH.REFRESH_TOKEN ||
+ originalRequest.url === URL_CONSTANTS.AUTH.LOGIN ||
+ originalRequest.url === URL_CONSTANTS.USERS.SIGN_UP
) {
return Promise.reject(error);
}
- if (isRefreshing) {
- return new Promise((resolve, reject) => {
- failedQueue.push({ resolve, reject });
- }).then((token) => {
- originalRequest.headers.Authorization = `Bearer ${token}`;
- return client(originalRequest);
- });
- }
-
- originalRequest._retry = true;
- isRefreshing = true;
-
- const refreshToken = getCookie("refresh-token");
-
- if (!refreshToken) {
- isRefreshing = false;
- clearAuthCookies();
+ // Nothing to refresh with (e.g. not logged in yet) — surface the
+ // original error instead of a confusing refresh failure.
+ if (!getCookie("refresh-token")) {
+ if (getCookie("auth-token")) {
+ // Half-broken cookie state; reset it.
+ clearAuthCookies();
+ }
return Promise.reject(error);
}
+ originalRequest._retry = true;
+
try {
- const { data } = await client.post<{
- data: { token: string; refreshToken: string };
- }>(URL_CONSTANTS.AUTH.REFRESH_TOKEN, { refreshToken });
- const { token, refreshToken: newRefreshToken } = data.data;
- setCookie("auth-token", token, 7);
- setCookie("refresh-token", newRefreshToken, 7);
+ const token = await refreshSessionTokens();
originalRequest.headers.Authorization = `Bearer ${token}`;
- processQueue(null, token);
return client(originalRequest);
} catch (refreshError) {
- processQueue(refreshError, undefined);
clearAuthCookies();
return Promise.reject(refreshError);
- } finally {
- isRefreshing = false;
}
},
);
-export { client };
+export { client, clearAuthCookies, getCookie, refreshSessionTokens };
export type { UseQueryOptions, QueryObserverOptions };
diff --git a/apps/edr-freight-web/portal/src/utils/refreshScheduler.ts b/apps/edr-freight-web/portal/src/utils/refreshScheduler.ts
new file mode 100644
index 000000000..dceca856e
--- /dev/null
+++ b/apps/edr-freight-web/portal/src/utils/refreshScheduler.ts
@@ -0,0 +1,81 @@
+import { isAxiosError } from "axios";
+
+import {
+ clearAuthCookies,
+ getCookie,
+ refreshSessionTokens,
+} from "./api";
+
+/**
+ * Proactively refreshes the token pair on a fixed cadence so the server-side
+ * session (a sliding 1-hour window, extended only by /auth/refresh-token) is
+ * kept alive while the app is open. The 401 interceptor in api.ts remains
+ * the reactive fallback; both share the same single-flight refresh call.
+ *
+ * The interval MUST stay well under the server session window (60 min).
+ */
+const DEFAULT_INTERVAL_MINUTES = 10;
+
+const getIntervalMs = () => {
+ const minutes = Number(import.meta.env.VITE_TOKEN_REFRESH_INTERVAL_MINUTES);
+ return (
+ (Number.isFinite(minutes) && minutes > 0
+ ? minutes
+ : DEFAULT_INTERVAL_MINUTES) * 60_000
+ );
+};
+
+let timerId: number | null = null;
+let lastRefreshAt = 0;
+
+const refreshNow = async () => {
+ if (!getCookie("refresh-token")) {
+ // Logged out elsewhere; nothing to keep alive.
+ stopTokenRefreshScheduler();
+ return;
+ }
+
+ try {
+ await refreshSessionTokens();
+ lastRefreshAt = Date.now();
+ } catch (error) {
+ // Network hiccups are retried on the next tick; only an explicit server
+ // rejection means the session is dead.
+ if (isAxiosError(error) && error.response) {
+ stopTokenRefreshScheduler();
+ clearAuthCookies();
+ window.location.replace("/login");
+ }
+ }
+};
+
+/**
+ * Browsers freeze timers in background tabs — a tab waking up past its
+ * refresh deadline refreshes immediately instead of waiting a full interval.
+ */
+const onVisibilityChange = () => {
+ if (document.visibilityState !== "visible") return;
+ if (Date.now() - lastRefreshAt >= getIntervalMs()) {
+ void refreshNow();
+ }
+};
+
+export const startTokenRefreshScheduler = () => {
+ stopTokenRefreshScheduler();
+
+ // Token age is unknown here (fresh login vs. hours-old page reload), so
+ // refresh right away to extend the session window from "now".
+ lastRefreshAt = 0;
+ void refreshNow();
+
+ timerId = window.setInterval(() => void refreshNow(), getIntervalMs());
+ document.addEventListener("visibilitychange", onVisibilityChange);
+};
+
+export const stopTokenRefreshScheduler = () => {
+ if (timerId !== null) {
+ window.clearInterval(timerId);
+ timerId = null;
+ }
+ document.removeEventListener("visibilitychange", onVisibilityChange);
+};
diff --git a/apps/edr-freight-web/portal/src/utils/result.ts b/apps/edr-freight-web/portal/src/utils/result.ts
index 3e9627445..54104442c 100644
--- a/apps/edr-freight-web/portal/src/utils/result.ts
+++ b/apps/edr-freight-web/portal/src/utils/result.ts
@@ -8,6 +8,32 @@ export type ApiError = {
statusCode?: number;
};
+/**
+ * Backend errors arrive as snake_case i18n-style codes (e.g.
+ * "unable_to_log_in"). Map the known ones to friendly copy and prettify
+ * anything else so raw codes never reach the UI. `code` stays raw for
+ * programmatic checks.
+ */
+const API_ERROR_MESSAGES: Record = {
+ unable_to_log_in: "Incorrect email or password.",
+ invalid_refresh_token: "Your session has expired. Please sign in again.",
+ session_expired: "Your session has expired. Please sign in again.",
+ session_not_found: "Your session has expired. Please sign in again.",
+ user_not_found: "No account found for these credentials.",
+};
+
+const SNAKE_CASE_CODE = /^[a-z0-9]+(?:_[a-z0-9]+)+$/;
+
+function humanizeApiMessage(raw: string): string {
+ const known = API_ERROR_MESSAGES[raw];
+ if (known) return known;
+ if (SNAKE_CASE_CODE.test(raw)) {
+ const text = raw.replaceAll("_", " ");
+ return `${text.charAt(0).toUpperCase()}${text.slice(1)}.`;
+ }
+ return raw;
+}
+
export function extractApiError(err: unknown): ApiError {
if (err && typeof err === "object") {
const obj = err as Record;
@@ -16,8 +42,12 @@ export function extractApiError(err: unknown): ApiError {
const statusCode = response.status as number | undefined;
const data = response.data as Record | undefined;
return {
- code: (data?.error as string) || (data?.message as string) || "api_error",
- message: (data?.message as string) || (data?.error as string) || "An unexpected error occurred",
+ code: (data?.message as string) || (data?.error as string) || "api_error",
+ message: humanizeApiMessage(
+ (data?.message as string) ||
+ (data?.error as string) ||
+ "An unexpected error occurred",
+ ),
statusCode,
};
}
diff --git a/apps/edr-passenger-api/src/app.module.ts b/apps/edr-passenger-api/src/app.module.ts
index 1e41076e7..59d73c55f 100644
--- a/apps/edr-passenger-api/src/app.module.ts
+++ b/apps/edr-passenger-api/src/app.module.ts
@@ -65,6 +65,8 @@ import { AppReleasesModule } from './modules/app-releases/app-releases.module';
import { ConfigurableFareModule } from './modules/configurable-fare/configurable-fare.module';
import { SegmentFareSeeder } from './seed/segment-fare.seeder';
+import { EOtpType } from "@tria-plc/iamapi-common";
+
@Module({
imports: [
ThrottlerModule.forRoot([
@@ -97,6 +99,16 @@ import { SegmentFareSeeder } from './seed/segment-fare.seeder';
TriaIamModule.forRoot({
applications: [EDR_PASSENGER_APPLICATION],
permissions: EDR_PASSENGER_PERMISSIONS,
+ otpMessages: {
+ [EOtpType.MFA_LOGIN]: ({ otp }) =>
+ `Your EDR Passenger login code is ${otp}. It will expire in 5 minutes.`,
+ [EOtpType.VERIFY_PHONE_NUMBER]: ({ otp }) =>
+ `Your EDR Passenger phone verification code is ${otp}. It will expire in 5 minutes.`,
+ [EOtpType.RESET_PASSWORD]: ({ route }) =>
+ `Reset your EDR Passenger password using this link: ${route}`,
+ [EOtpType.SET_PASSWORD]: ({ route }) =>
+ `Set your EDR Passenger password using this link: ${route}`,
+ },
}),
SharedAuthModule,
PrismaModule,
diff --git a/apps/edr-passenger-api/src/main.ts b/apps/edr-passenger-api/src/main.ts
index cbdae37e3..26f57e506 100644
--- a/apps/edr-passenger-api/src/main.ts
+++ b/apps/edr-passenger-api/src/main.ts
@@ -60,7 +60,7 @@ async function bootstrap() {
Enterprise-grade REST API for the Ethio-Djibouti Railway passenger booking and management platform. Built with NestJS, TypeScript, PostgreSQL, and Prisma ORM.
## Latest Updates
-- **Enhanced Module Coverage:** Complete API coverage with 25+ core modules including System Config, Excess Baggage, Packages, and comprehensive CRUD operations across all entities.
+- **Enhanced Module Coverage:** Complete API coverage with 25+ core modules including System Config, Excess Luggage, Packages, and comprehensive CRUD operations across all entities.
- **Health Check Endpoints:** Three public probes added under \`/health\`. Liveness (\`GET /health\`), readiness with live DB ping (\`GET /health/ready\`), and app info (\`GET /health/info\`). All are exempt from rate limiting.
- **Rate Limiting:** Global throttle enforced via ThrottlerGuard with three named tiers: auth (5 req/min on \`/auth\` and \`/fayda/verification\`), strict (20 req/min on \`/bookings\`, \`/passengers\`, \`/payments\`, \`/wallet\`), default (100 req/min everywhere else). Health probes, webhook handlers, and internal service endpoints are exempt.
- **Boarding Pass on Gate Validation:** Every successful gate validation at \`POST /tickets/:ref/validate\` now automatically delivers a boarding pass to the passenger via email (full HTML with QR code, route, seat table) and SMS (compact text with ref, route, seats, barcode). The leg label (OUTBOUND, RETURN, LEG1, etc.) is included so passengers know which boarding it covers.
@@ -328,7 +328,7 @@ Payment providers send notifications to:
"JWT-auth",
)
.addTag("Agents", "Counter booking, shift management, commission tracking, and reconciliation")
- .addTag("Excess Baggage", "IAM-protected agent/supervisor endpoints to log excess baggage charges, waive fees, resend payment links, and manage allowance rules per seat class. Public token-based endpoints let passengers self-pay outstanding charges.")
+ .addTag("Excess Luggage", "IAM-protected agent/supervisor endpoints to log excess baggage charges, waive fees, resend payment links, and manage allowance rules per seat class. Public token-based endpoints let passengers self-pay outstanding charges.")
.addTag("Packages", "Bundled travel packages with tiered pricing. Public endpoints for browsing and booking; JWT-authenticated endpoints for purchase history; IAM-protected endpoints for admin CRUD and tier management.")
.addTag("Config", "System-wide configuration management including feature flags, maintenance modes, and operational parameters. IAM-protected endpoints for administrative control.")
.addTag("Audit", "User activity logging, system changes, compliance tracking, and audit trails")
diff --git a/apps/edr-passenger-api/src/modules/bookings/bookings.dto.ts b/apps/edr-passenger-api/src/modules/bookings/bookings.dto.ts
index 345cfc357..d50f48592 100644
--- a/apps/edr-passenger-api/src/modules/bookings/bookings.dto.ts
+++ b/apps/edr-passenger-api/src/modules/bookings/bookings.dto.ts
@@ -21,6 +21,8 @@ export class PassengerInputDto {
@ApiPropertyOptional({ example: 'P1234567', description: 'Passport number for non-Ethiopian passengers (no verification)' }) @IsOptional() @IsString() passportNumber?: string;
@ApiPropertyOptional({ example: 'Djibouti', description: 'Passport issuing country for non-Ethiopians' }) @IsOptional() @IsString() passportCountry?: string;
@ApiPropertyOptional({ example: 'Ethiopian', description: 'Ethiopian (Verifayda + Telebirr/CBE/eBirr), Djiboutian (Passport + Waafi), Other (Passport + Card)' }) @IsOptional() @IsString() nationality?: string;
+ @ApiPropertyOptional({ example: 35000, description: 'Actual fare for this passenger in minor units (ETB). When provided, overrides the fare engine calculation — use for berth-specific pricing (Upper/Middle/Lower).' }) @IsOptional() @IsInt() seatFareMinor?: number;
+ @ApiPropertyOptional({ example: 35000, description: 'Return leg fare for this passenger in minor units (ETB). Used for ROUND_TRIP berth-specific pricing.' }) @IsOptional() @IsInt() returnSeatFareMinor?: number;
}
export class RoundTripPassengerDto {
@@ -143,6 +145,9 @@ export class CreateBookingDto {
@ApiPropertyOptional({ description: 'Package price tier ID — required when packageId is provided' })
@IsOptional() @IsString() priceTierId?: string;
+ @ApiPropertyOptional({ description: 'Total amount in minor units (ETB) as computed and displayed on the review page. When provided, this overrides the fare engine total — use to pass the exact berth-specific amount the user saw.' })
+ @IsOptional() @IsInt() reviewedTotalMinor?: number;
+
@ApiPropertyOptional({ description: 'Promo code for discount (applies to combined fare for round-trip)' })
@IsOptional() @IsString() promoCode?: string;
diff --git a/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts b/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts
index a2ea23e09..b784ea111 100644
--- a/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts
+++ b/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts
@@ -1,4 +1,4 @@
-import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
+import { Injectable, NotFoundException, BadRequestException, Logger } from '@nestjs/common';
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
import { PrismaService } from '../../common/prisma.service';
@@ -17,6 +17,7 @@ function generateRef(): string {
return Array.from({ length: 6 }, () => chars[Math.floor(Math.random() * chars.length)]).join('');
}
+
/**
* For package round-trip bookings, totalMinor in the DB may have been stored as a
* single-leg amount before the server fix. Recompute from the tier price when needed.
@@ -59,6 +60,8 @@ interface BookingFilters {
@Injectable()
export class BookingsService {
+ private readonly logger = new Logger(BookingsService.name);
+
constructor(
private readonly prisma: PrismaService,
@InjectDataSource() private readonly dataSource: DataSource,
@@ -545,30 +548,42 @@ export class BookingsService {
: await this.calculateFare(dto.scheduleId, dto.seatClassId, originStop, destStop, passengersData[0]?.nationality, adultCount, childCount, dto.promoCode, dto.loyaltyRedemptionPoints);
const displayCurrency = dto.displayCurrency || Currency.ETB;
- let displayTotalMinor = fareCalculation.totalMinor;
- if (displayCurrency !== Currency.ETB) {
- displayTotalMinor = await this.currencyService.convertAmount(fareCalculation.totalMinor, Currency.ETB, displayCurrency);
- }
- // Track per-seat fare. For package bookings children pay 10% of adult fare;
- // for regular bookings the first child is free.
+ // Track per-seat fare. Use the client-supplied seatFareMinor when present (berth-specific
+ // pricing for Upper/Middle/Lower beds). Fall back to the fare engine's baseFareMinor.
let freeChildUsed = false;
+ let pkgChildIdx = 0;
const passengersWithFares = passengersData.map(p => {
let fareMinor: number;
if (p.category === PassengerCategory.ADULT) {
- fareMinor = fareCalculation.baseFareMinor;
+ fareMinor = p.seatFareMinor ?? fareCalculation.baseFareMinor;
} else if (dto.packageId) {
- // Free children (first per adult) get fareMinor=0; paid children pay full adult fare.
- // passengersWithFares is built in adult-first order so we track paid children by count.
- const childIdx = passengersWithFares.filter(x => x.category !== PassengerCategory.ADULT).length;
- fareMinor = childIdx < adultCount ? 0 : fareCalculation.baseFareMinor;
+ fareMinor = pkgChildIdx < adultCount ? 0 : (p.seatFareMinor ?? fareCalculation.baseFareMinor);
+ pkgChildIdx++;
} else {
if (!freeChildUsed) { fareMinor = 0; freeChildUsed = true; }
- else fareMinor = fareCalculation.baseFareMinor;
+ else fareMinor = p.seatFareMinor ?? fareCalculation.baseFareMinor;
}
return { ...p, fareMinor };
});
+ // Use the sum of per-seat fares as the authoritative total when the client supplied
+ // seatFareMinor for every seat-holding passenger — this captures berth-specific pricing
+ // (Upper/Middle/Lower) that the fare engine cannot resolve from seatClassId alone.
+ // Free children have no seatId and no seatFareMinor — exclude them from the check.
+ const seatedPassengers = passengersData.filter(p => p.seatId);
+ const allFaresProvided = seatedPassengers.length > 0 && seatedPassengers.every(p => p.seatFareMinor != null);
+ const resolvedTotalMinor = dto.reviewedTotalMinor ??
+ (allFaresProvided
+ ? passengersWithFares.reduce((sum, p) => sum + p.fareMinor, 0)
+ : fareCalculation.totalMinor);
+ this.logger.log(`createOneWayBooking: resolvedTotalMinor=${resolvedTotalMinor} (reviewedTotalMinor=${dto.reviewedTotalMinor} allFaresProvided=${allFaresProvided} fareEngine=${fareCalculation.totalMinor})`);
+
+ let displayTotalMinor = resolvedTotalMinor;
+ if (displayCurrency !== Currency.ETB) {
+ displayTotalMinor = await this.currencyService.convertAmount(resolvedTotalMinor, Currency.ETB, displayCurrency);
+ }
+
const booking = await this.prisma.booking.create({
data: {
bookingRef: generateRef(),
@@ -576,7 +591,7 @@ export class BookingsService {
scheduleId: dto.scheduleId,
status: 'PENDING_PAYMENT',
bookingType: 'ONE_WAY',
- totalMinor: fareCalculation.totalMinor,
+ totalMinor: resolvedTotalMinor / 100,
adultCount,
childCount,
displayCurrency,
@@ -696,8 +711,8 @@ export class BookingsService {
displayTotalMinor = await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency);
}
- // Track per-seat fare. For package bookings children pay 10% of adult fare;
- // for regular bookings the first child is free per leg.
+ // Track per-seat fare. Use client-supplied seatFareMinor/returnSeatFareMinor when
+ // present (berth-specific pricing). Fall back to fare engine values.
let outboundFreeChildUsed = false;
let returnFreeChildUsed = false;
const passengersWithFares = passengersData.map(p => {
@@ -705,24 +720,40 @@ export class BookingsService {
let returnFareMinor: number;
if (p.category === PassengerCategory.ADULT) {
- outboundFareMinor = outboundFare.baseFareMinor;
- returnFareMinor = returnFare.baseFareMinor;
+ outboundFareMinor = p.seatFareMinor ?? outboundFare.baseFareMinor;
+ returnFareMinor = p.returnSeatFareMinor ?? returnFare.baseFareMinor;
} else if (dto.packageId) {
- // Free children (first per adult) get fareMinor=0; paid children pay full adult fare.
- const childIdx = passengersWithFares.filter(x => x.category !== PassengerCategory.ADULT).length;
- const isFreeChild = childIdx < adultCount;
- outboundFareMinor = isFreeChild ? 0 : outboundFare.baseFareMinor;
- returnFareMinor = isFreeChild ? 0 : returnFare.baseFareMinor;
+ outboundFareMinor = 0;
+ returnFareMinor = 0;
} else {
if (!outboundFreeChildUsed) { outboundFareMinor = 0; outboundFreeChildUsed = true; }
- else outboundFareMinor = outboundFare.baseFareMinor;
+ else outboundFareMinor = p.seatFareMinor ?? outboundFare.baseFareMinor;
if (!returnFreeChildUsed) { returnFareMinor = 0; returnFreeChildUsed = true; }
- else returnFareMinor = returnFare.baseFareMinor;
+ else returnFareMinor = p.returnSeatFareMinor ?? returnFare.baseFareMinor;
}
return { ...p, outboundFareMinor, returnFareMinor };
});
+ // Override totalMinor with the sum of actual per-seat fares when all seated passengers
+ // supplied their fares — free children (no seatId) are excluded from the check.
+ const rtSeatedPassengers = passengersData.filter(p => p.outboundSeatId);
+ const allRTFaresProvided = rtSeatedPassengers.length > 0 &&
+ rtSeatedPassengers.every(p => p.seatFareMinor != null && p.returnSeatFareMinor != null);
+ if (dto.reviewedTotalMinor) {
+ totalMinor = dto.reviewedTotalMinor;
+ displayTotalMinor = displayCurrency !== Currency.ETB
+ ? await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency)
+ : totalMinor;
+ } else if (allRTFaresProvided && !dto.packageId) {
+ totalMinor = passengersWithFares.reduce((sum, p) => sum + p.outboundFareMinor + p.returnFareMinor, 0);
+ if (displayCurrency !== Currency.ETB) {
+ displayTotalMinor = await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency);
+ } else {
+ displayTotalMinor = totalMinor;
+ }
+ }
+
const booking = await this.prisma.booking.create({
data: {
bookingRef: generateRef(),
diff --git a/apps/edr-passenger-api/src/modules/bookings/guest-booking.dto.ts b/apps/edr-passenger-api/src/modules/bookings/guest-booking.dto.ts
index c2acaeece..f5a5559bb 100644
--- a/apps/edr-passenger-api/src/modules/bookings/guest-booking.dto.ts
+++ b/apps/edr-passenger-api/src/modules/bookings/guest-booking.dto.ts
@@ -1,4 +1,4 @@
-import { IsString, IsArray, ValidateNested, IsOptional, IsEnum, IsDateString, IsBoolean } from 'class-validator';
+import { IsString, IsArray, ValidateNested, IsOptional, IsEnum, IsDateString, IsBoolean, IsInt } from 'class-validator';
import { Type } from 'class-transformer';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Currency, IdDocumentType } from '@prisma/client';
@@ -42,6 +42,12 @@ export class GuestPassengerDto {
@ApiPropertyOptional({ example: 'abebe@email.com', description: 'Contact email' })
@IsOptional() @IsString() email?: string;
+
+ @ApiPropertyOptional({ example: 35000, description: 'Actual fare for this passenger in minor units (ETB). Overrides fare engine — use for berth-specific pricing (Upper/Middle/Lower).' })
+ @IsOptional() @IsInt() seatFareMinor?: number;
+
+ @ApiPropertyOptional({ example: 35000, description: 'Return leg fare for this passenger in minor units (ETB). Used for ROUND_TRIP berth-specific pricing.' })
+ @IsOptional() @IsInt() returnSeatFareMinor?: number;
}
export class CreateGuestBookingDto {
@@ -150,6 +156,9 @@ export class CreateGuestBookingDto {
@ApiPropertyOptional({ description: 'Package price tier ID — required when packageId is provided' })
@IsOptional() @IsString() priceTierId?: string;
+
+ @ApiPropertyOptional({ description: 'Total amount in minor units (ETB) as computed and displayed on the review page. When provided, overrides the fare engine total — use to pass the exact berth-specific amount the user saw.' })
+ @IsOptional() @IsInt() reviewedTotalMinor?: number;
}
export class SavedPassengerProfileDto {
diff --git a/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts b/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts
index 1025a42b3..fb218e052 100644
--- a/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts
+++ b/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts
@@ -14,7 +14,7 @@ const BOOKING_CUTOFF_MS = 30 * 60 * 1000;
function generateRef(): string {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
- return 'EDR-' + Array.from({ length: 6 }, () => chars[Math.floor(Math.random() * chars.length)]).join('');
+ return Array.from({ length: 6 }, () => chars[Math.floor(Math.random() * chars.length)]).join('');
}
// Ethiopian mobile prefixes: Ethio Telecom (09xx) and Safaricom ET (07xx)
@@ -185,12 +185,38 @@ export class GuestBookingService {
}
const taxesMinor = 0;
- const totalMinor = Math.max(0, totalBaseFareMinor - discountMinor);
+
+ // Per-seat fare: use client-supplied seatFareMinor when present (berth-specific pricing).
+ // Free children (first child, non-package) get fareMinor=0.
+ let freeChildUsed = false;
+ let pkgChildIdx = 0;
+ const passengersWithFares = passengersData.map(p => {
+ let fareMinor: number;
+ if (p.category === PassengerCategory.ADULT) {
+ fareMinor = p.seatFareMinor ?? baseFareMinor;
+ } else if (isPackageOneway) {
+ fareMinor = pkgChildIdx < adultCount ? 0 : (p.seatFareMinor ?? childUnitFare);
+ pkgChildIdx++;
+ } else {
+ if (!freeChildUsed) { fareMinor = 0; freeChildUsed = true; }
+ else fareMinor = p.seatFareMinor ?? childUnitFare;
+ }
+ return { ...p, fareMinor };
+ });
+
+ // Use reviewedTotalMinor from frontend as authoritative total when provided.
+ // Fall back to per-seat sum when all seated passengers supplied seatFareMinor.
+ const seatedPassengers = passengersData.filter(p => p.seatId);
+ const allFaresProvided = seatedPassengers.length > 0 && seatedPassengers.every(p => p.seatFareMinor != null);
+ const resolvedTotalMinor = dto.reviewedTotalMinor ??
+ (allFaresProvided
+ ? passengersWithFares.reduce((sum, p) => sum + p.fareMinor, 0)
+ : Math.max(0, totalBaseFareMinor - discountMinor));
const displayCurrency = dto.displayCurrency || Currency.ETB;
- let displayTotalMinor = totalMinor;
+ let displayTotalMinor = resolvedTotalMinor;
if (displayCurrency !== Currency.ETB) {
- displayTotalMinor = await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency);
+ displayTotalMinor = await this.currencyService.convertAmount(resolvedTotalMinor, Currency.ETB, displayCurrency);
}
// Resolve or create the guest Passenger record
@@ -224,7 +250,7 @@ export class GuestBookingService {
passengerId: guestPassengerId,
scheduleId: dto.scheduleId,
status: 'PENDING_PAYMENT',
- totalMinor,
+ totalMinor: resolvedTotalMinor,
adultCount,
childCount,
displayCurrency,
@@ -235,7 +261,7 @@ export class GuestBookingService {
contactEmail: firstPassenger.email || null,
contactPhone: firstPassenger.phone || null,
seats: {
- create: passengersData.map((p) => ({
+ create: passengersWithFares.map((p) => ({
seat: { connect: { id: p.seatId } },
passengerName: p.passengerName,
dateOfBirth: p.dateOfBirth,
@@ -245,7 +271,7 @@ export class GuestBookingService {
passportCountry: p.passportCountry,
verifaydaVerified: p.verifaydaVerified,
verifaydaData: p.verifaydaData || undefined,
- fareMinor: p.category === PassengerCategory.ADULT ? baseFareMinor : childUnitFare,
+ fareMinor: p.fareMinor,
displayCurrency,
})),
},
@@ -278,7 +304,7 @@ export class GuestBookingService {
totalBaseFareMinor,
discountMinor,
taxesFeesMinor: taxesMinor,
- totalMinor,
+ totalMinor: resolvedTotalMinor,
currency: 'ETB',
displayCurrency,
displayTotalMinor,
@@ -423,13 +449,51 @@ export class GuestBookingService {
}
const taxesMinor = 0;
- const totalMinor = Math.max(0, combinedBaseFareMinor - discountMinor);
+ let totalMinor = Math.max(0, combinedBaseFareMinor - discountMinor);
const displayCurrency = dto.displayCurrency || Currency.ETB;
- const displayTotalMinor = displayCurrency !== Currency.ETB
+ let displayTotalMinor = displayCurrency !== Currency.ETB
? await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency)
: totalMinor;
+ // Per-seat fares: use client-supplied seatFareMinor/returnSeatFareMinor when present.
+ let outboundFreeChildUsed = false;
+ let returnFreeChildUsed = false;
+ const passengersWithFares = passengersData.map(p => {
+ let outboundFareMinor: number;
+ let returnFareMinor: number;
+ if (p.category === PassengerCategory.ADULT) {
+ outboundFareMinor = p.seatFareMinor ?? outboundBaseFare;
+ returnFareMinor = p.returnSeatFareMinor ?? returnBaseFare;
+ } else if (isPackageRoundTrip) {
+ outboundFareMinor = 0;
+ returnFareMinor = 0;
+ } else {
+ if (!outboundFreeChildUsed) { outboundFareMinor = 0; outboundFreeChildUsed = true; }
+ else outboundFareMinor = p.seatFareMinor ?? outboundChildUnitFare;
+ if (!returnFreeChildUsed) { returnFareMinor = 0; returnFreeChildUsed = true; }
+ else returnFareMinor = p.returnSeatFareMinor ?? returnChildUnitFare;
+ }
+ return { ...p, outboundFareMinor, returnFareMinor };
+ });
+
+ // Override totalMinor with reviewedTotalMinor when provided, or sum of per-seat fares
+ // when all seated passengers supplied their fares.
+ const rtSeatedPassengers = passengersData.filter(p => p.seatId);
+ const allRTFaresProvided = rtSeatedPassengers.length > 0 &&
+ rtSeatedPassengers.every(p => p.seatFareMinor != null && p.returnSeatFareMinor != null);
+ if (dto.reviewedTotalMinor) {
+ totalMinor = dto.reviewedTotalMinor;
+ displayTotalMinor = displayCurrency !== Currency.ETB
+ ? await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency)
+ : totalMinor;
+ } else if (allRTFaresProvided && !isPackageRoundTrip) {
+ totalMinor = passengersWithFares.reduce((sum, p) => sum + p.outboundFareMinor + p.returnFareMinor, 0);
+ displayTotalMinor = displayCurrency !== Currency.ETB
+ ? await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency)
+ : totalMinor;
+ }
+
// Create or resolve guest passenger (same as one-way)
const { guestPassengerId, iamUserId, createdAccount } = await this.resolveGuestPassenger(dto, passengersData[0], req);
@@ -461,7 +525,7 @@ export class GuestBookingService {
contactPhone: passengersData[0]?.phone || null,
seats: {
create: [
- ...passengersData.map((p) => ({
+ ...passengersWithFares.map((p) => ({
seat: { connect: { id: p.seatId } },
leg: 1,
scheduleId: dto.scheduleId,
@@ -473,10 +537,10 @@ export class GuestBookingService {
passportCountry: p.passportCountry,
verifaydaVerified: p.verifaydaVerified,
verifaydaData: p.verifaydaData || undefined,
- fareMinor: p.category === PassengerCategory.ADULT ? outboundBaseFare : outboundChildUnitFare,
+ fareMinor: p.outboundFareMinor,
displayCurrency,
})),
- ...passengersData.map((p) => ({
+ ...passengersWithFares.map((p) => ({
seat: { connect: { id: p.returnSeatId } },
leg: 2,
scheduleId: dto.returnScheduleId,
@@ -488,7 +552,7 @@ export class GuestBookingService {
passportCountry: p.passportCountry,
verifaydaVerified: p.verifaydaVerified,
verifaydaData: p.verifaydaData || undefined,
- fareMinor: p.category === PassengerCategory.ADULT ? returnBaseFare : returnChildUnitFare,
+ fareMinor: p.returnFareMinor,
displayCurrency,
})),
],
diff --git a/apps/edr-passenger-api/src/modules/currency/currency.service.ts b/apps/edr-passenger-api/src/modules/currency/currency.service.ts
index 4666d4aaa..304add11c 100644
--- a/apps/edr-passenger-api/src/modules/currency/currency.service.ts
+++ b/apps/edr-passenger-api/src/modules/currency/currency.service.ts
@@ -30,23 +30,59 @@ export class CurrencyService {
private readonly configService: ConfigService,
) {}
+ /**
+ * Converts a stored display-currency minor amount to the charge major amount
+ * sent to the payment provider, without hitting the DB for an exchange rate.
+ * Use this when the payment method's settlement currency matches the booking's
+ * displayCurrency — the rate is already baked into displayTotalMinor.
+ */
+ displayMinorToChargeMajor(displayMinor: number, currency: string): number {
+ const decimals = CHARGE_CURRENCY_DECIMALS[currency.toUpperCase()];
+ if (decimals === undefined) {
+ throw new BadRequestException(`Unsupported charge currency: ${currency}`);
+ }
+ return this.roundTo(displayMinor / 100, decimals);
+ }
+
+ async convertEtbMinorToChargeMinor(
+ amountMinorEtb: number,
+ targetCurrency: string,
+ ): Promise {
+ const target = targetCurrency.toUpperCase();
+ if (CHARGE_CURRENCY_DECIMALS[target] === undefined) {
+ throw new BadRequestException(`Unsupported charge currency: ${targetCurrency}`);
+ }
+
+ if (target === Currency.ETB) {
+ return amountMinorEtb;
+ }
+
+ // Convert ETB minor → target minor: apply exchange rate, keep as minor units.
+ const rate = await this.getRateOrThrow(Currency.ETB, target as Currency);
+ return Math.round(amountMinorEtb * rate);
+ }
+
+ /**
+ * Converts an ETB minor-unit amount to the charge major-unit amount sent to the
+ * payment provider. Applies the exchange rate for foreign currencies then divides
+ * by 100 to yield major units (e.g. 300000 ETB minor → 3000.00 ETB major).
+ */
async convertEtbMinorToChargeMajor(
amountMinorEtb: number,
targetCurrency: string,
): Promise {
const target = targetCurrency.toUpperCase();
- const decimals = CHARGE_CURRENCY_DECIMALS[target];
- if (decimals === undefined) {
+ if (CHARGE_CURRENCY_DECIMALS[target] === undefined) {
throw new BadRequestException(`Unsupported charge currency: ${targetCurrency}`);
}
+ const decimals = CHARGE_CURRENCY_DECIMALS[target];
- const sourceMajor = amountMinorEtb / 100;
if (target === Currency.ETB) {
- return this.roundTo(sourceMajor, decimals);
+ return this.roundTo(amountMinorEtb / 100, decimals);
}
const rate = await this.getRateOrThrow(Currency.ETB, target as Currency);
- return this.roundTo(sourceMajor * rate, decimals);
+ return this.roundTo((amountMinorEtb * rate) / 100, decimals);
}
async getRateOrThrow(
diff --git a/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage.controller.ts b/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage.controller.ts
index e65a6be68..8d01e7113 100644
--- a/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage.controller.ts
+++ b/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage.controller.ts
@@ -17,7 +17,7 @@ class UpsertBaggageAllowanceDto {
}
// ── IAM-protected agent/supervisor routes ────────────────────────────────────
-@ApiTags('Excess Baggage')
+@ApiTags('Excess Luggage')
@Controller('agents/excess-baggage')
@UseGuards(IamJwtGuard)
@ApiBearerAuth('IAM-auth')
@@ -100,7 +100,7 @@ export class ExcessBaggageAgentController {
}
// ── Public pay-by-token routes (passenger self-service) ──────────────────────
-@ApiTags('Excess Baggage')
+@ApiTags('Excess Luggage')
@Controller('excess-baggage')
export class ExcessBaggagePublicController {
constructor(private service: ExcessBaggageService) {}
diff --git a/apps/edr-passenger-api/src/modules/fare-engine/fare-engine.service.ts b/apps/edr-passenger-api/src/modules/fare-engine/fare-engine.service.ts
index 70f18564d..dd4e15b9c 100644
--- a/apps/edr-passenger-api/src/modules/fare-engine/fare-engine.service.ts
+++ b/apps/edr-passenger-api/src/modules/fare-engine/fare-engine.service.ts
@@ -30,13 +30,10 @@ export class FareEngineService {
if (!seatClass) throw new NotFoundException('Seat class not found');
if (!seatClass.isActive) throw new BadRequestException('Seat class is not active');
- // Resolve nationality type: Ethiopian and Djiboutian are LOCAL, everyone else INTERNATIONAL
const nationalityUpper = (dto.nationality ?? '').toUpperCase();
const nationalityType = (nationalityUpper === 'ETHIOPIAN' || nationalityUpper === 'DJIBOUTIAN')
? 'LOCAL' : 'INTERNATIONAL';
- // Find the nationality-specific seat class for the same coach type and bed position.
- // Falls back to the requested seatClass if no nationality-specific one exists.
const nationalitySeatClass = await this.prisma.seatClass.findFirst({
where: {
coachTypeId: seatClass.coachTypeId,
@@ -46,13 +43,10 @@ export class FareEngineService {
},
}) ?? seatClass;
- // Calculate distance: distanceKm represents cumulative distance from route origin
- // For a segment, distance = destination.distanceKm - origin.distanceKm
const totalDistanceKm = destStop.distanceKm! - originStop.distanceKm!;
if (totalDistanceKm < 0 || isNaN(totalDistanceKm))
throw new BadRequestException('Invalid distance calculation - check route stop distances');
- // Resolve fare: FareRule (schedule-scoped → route-scoped) takes precedence over distance×rate
const now = new Date();
const [originStation, destStation] = await Promise.all([
this.prisma.station.findUnique({ where: { id: dto.originStationId } }),
@@ -81,8 +75,12 @@ export class FareEngineService {
let baseFarePerPassengerMinor: number;
let ratePerKmMinor: number;
let fareSource: string;
+ let insuranceFactor = 1;
+ let usdToEtbRate = 1;
+ // When insuranceFeeMinor is used as a multiplier in the formula it must not
+ // be added again as a flat fee. This flag tracks that.
+ let insuranceAlreadyInBase = false;
- // 1. Segment override: exact origin→destination stop pair on this route
const segmentOverride = await this.prisma.segmentFareRule.findFirst({
where: {
routeId: route.id,
@@ -106,39 +104,52 @@ export class FareEngineService {
});
if (segmentOverride) {
- // Flat override for this exact segment — baseFareMinor is the total base, not a per-km rate
baseFarePerPassengerMinor = segmentOverride.baseFareMinor;
+ if (nationalityType === 'INTERNATIONAL' && !segmentOverride.nationality) {
+ baseFarePerPassengerMinor *= 2;
+ }
ratePerKmMinor = totalDistanceKm > 0 ? Math.round(baseFarePerPassengerMinor / totalDistanceKm) : 0;
fareSource = 'SEGMENT_FARE_RULE';
} else if (fareRule?.tripId) {
- // Schedule-scoped flat override
baseFarePerPassengerMinor = fareRule.baseFareMinor;
+ if (nationalityType === 'INTERNATIONAL' && !fareRule.nationality) {
+ baseFarePerPassengerMinor *= 2;
+ }
ratePerKmMinor = totalDistanceKm > 0 ? Math.round(baseFarePerPassengerMinor / totalDistanceKm) : 0;
fareSource = 'SCHEDULE_FARE_RULE';
} else {
- // Default: distance-based using tariff formula: km × rate × 1.02
- // baseFareMinor stores the per-km rate (tariff decimal × 100000)
+ // Distance-based formula:
+ // baseFare (minor) = distanceKm × (baseFareMinor / 100) × insuranceFactor × usdToEtbRate
+ // baseFareMinor stored as integer (e.g. 300 = 3.00 ETB/km), divided by 100 to get ETB/km.
+ // insuranceFeeMinor stored as integer (e.g. 102 = 1.02 multiplier), divided by 100; defaults to 1 if unset.
+ // usdToEtbRate fetched live from CurrencyExchangeRate table.
+ // Insurance is already baked into baseFarePerPassengerMinor — do NOT add it again as a flat fee.
+ const ratePerKmEtb = nationalitySeatClass.baseFareMinor / 100;
+ insuranceFactor = nationalitySeatClass.insuranceFeeMinor > 0
+ ? nationalitySeatClass.insuranceFeeMinor / 100
+ : 1;
+ usdToEtbRate = await this.currencyService.getExchangeRate(Currency.USD, Currency.ETB);
ratePerKmMinor = nationalitySeatClass.baseFareMinor;
- baseFarePerPassengerMinor = Math.round(ratePerKmMinor * totalDistanceKm * 1.02);
+ baseFarePerPassengerMinor = Math.round(
+ totalDistanceKm * ratePerKmEtb * insuranceFactor * usdToEtbRate,
+ );
fareSource = 'SEAT_CLASS_BASE_FARE';
+ insuranceAlreadyInBase = true;
}
- // Premium and insurance fees applied per passenger
- const premiumPerPassenger = seatClass.premiumMinor ?? 0;
- const insurancePerPassenger = seatClass.insuranceFeeMinor ?? 0;
- const farePerPassengerMinor = baseFarePerPassengerMinor + premiumPerPassenger + insurancePerPassenger;
+ const premiumPerPassenger = seatClass.premiumMinor ?? 0;
+ const insurancePerPassenger = insuranceAlreadyInBase ? 0 : (seatClass.insuranceFeeMinor ?? 0);
+ const farePerPassengerMinor = baseFarePerPassengerMinor + premiumPerPassenger + insurancePerPassenger;
const adultCount = dto.adultCount ?? 1;
const childCount = dto.childCount ?? 0;
const freeChildrenCount = Math.min(childCount, adultCount);
const paidChildrenCount = Math.max(0, childCount - freeChildrenCount);
- // Subtotal includes: (distance-based fare + premium + insurance) × passengers
- // First child is free, but pays premium and insurance
- const adultSubtotal = farePerPassengerMinor * adultCount;
+ const adultSubtotal = farePerPassengerMinor * adultCount;
const freeChildSubtotal = (premiumPerPassenger + insurancePerPassenger) * freeChildrenCount;
const paidChildSubtotal = farePerPassengerMinor * paidChildrenCount;
- const subtotalMinor = adultSubtotal + freeChildSubtotal + paidChildSubtotal;
+ const subtotalMinor = adultSubtotal + freeChildSubtotal + paidChildSubtotal;
let discountMinor = 0;
let promoLabel = 'none';
@@ -152,8 +163,7 @@ export class FareEngineService {
}
}
- const afterDiscountMinor = subtotalMinor - discountMinor;
- const totalEtbMinor = afterDiscountMinor;
+ const totalEtbMinor = subtotalMinor - discountMinor;
const billingCurrency = resolveCurrencyFromNationality(dto.nationality);
const exchangeRate = await this.currencyService.getExchangeRate(Currency.ETB, billingCurrency);
@@ -161,9 +171,11 @@ export class FareEngineService {
const calculation = [
`Distance: ${totalDistanceKm} km (${originStation?.name} → ${destStation?.name})`,
- `Nationality: ${dto.nationality ?? 'unspecified'} → ${nationalityType} → ${nationalitySeatClass.name}`,
- `Rate per km: ${ratePerKmMinor} ETB minor (${nationalitySeatClass.name})`,
- `Base fare/pax: ${totalDistanceKm} km × ${ratePerKmMinor} × 1.02 = ${baseFarePerPassengerMinor} ETB minor`,
+ `Nationality: ${dto.nationality ?? 'unspecified'} → ${nationalityType}${nationalityType === 'INTERNATIONAL' ? ' (2× surcharge applied)' : ''} → ${nationalitySeatClass.name}`,
+ `Rate per km: ${nationalitySeatClass.baseFareMinor} minor → ${nationalitySeatClass.baseFareMinor / 100} ETB/km`,
+ `Insurance: ${nationalitySeatClass.insuranceFeeMinor} minor → factor ${insuranceFactor}${insuranceAlreadyInBase ? ' (baked into base fare)' : ''}`,
+ `USD→ETB rate: ${usdToEtbRate}`,
+ `Base fare/pax: ${totalDistanceKm} km × (${nationalitySeatClass.baseFareMinor} / 100) × ${insuranceFactor} × ${usdToEtbRate} = ${baseFarePerPassengerMinor} ETB minor`,
`Premium/pax: ${premiumPerPassenger} ETB minor`,
`Insurance/pax: ${insurancePerPassenger} ETB minor`,
`Total fare/pax: ${farePerPassengerMinor} ETB minor`,
@@ -191,6 +203,8 @@ export class FareEngineService {
seatClassName: nationalitySeatClass.name,
totalDistanceKm,
ratePerKmMinor,
+ insuranceFactor,
+ usdToEtbRate,
baseFarePerPassengerMinor,
premiumPerPassenger,
insurancePerPassenger,
@@ -323,19 +337,16 @@ export class FareEngineService {
if (fareRules.length > 0) {
const billingCurrency = resolveCurrencyFromNationality(nationality);
const exchangeRate = await this.currencyService.getExchangeRate(Currency.ETB, billingCurrency);
- return fareRules.map(rule => {
- const seatClassId = rule.seatClassId;
- return {
- seatClassId,
- seatClassName: 'Unknown',
- baseFareMinor: rule.baseFareMinor,
- totalMinor: rule.baseFareMinor,
- billingCurrency,
- totalInBillingCurrency: Math.round(rule.baseFareMinor * exchangeRate),
- exchangeRate,
- source: 'FARE_RULE',
- };
- });
+ return fareRules.map(rule => ({
+ seatClassId: rule.seatClassId,
+ seatClassName: 'Unknown',
+ baseFareMinor: rule.baseFareMinor,
+ totalMinor: rule.baseFareMinor,
+ billingCurrency,
+ totalInBillingCurrency: Math.round(rule.baseFareMinor * exchangeRate),
+ exchangeRate,
+ source: 'FARE_RULE',
+ }));
}
throw new BadRequestException(
diff --git a/apps/edr-passenger-api/src/modules/notifications/notifications.controller.ts b/apps/edr-passenger-api/src/modules/notifications/notifications.controller.ts
index f27bd8ecd..99ce32b08 100644
--- a/apps/edr-passenger-api/src/modules/notifications/notifications.controller.ts
+++ b/apps/edr-passenger-api/src/modules/notifications/notifications.controller.ts
@@ -4,7 +4,7 @@ import { NotificationsService } from './notifications.service';
import { JwtGuard } from '../../common/jwt.guard';
import { PassengerStaff } from '../../common/passenger-guards';
import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry';
-import { TestNotificationDto } from './notifications.dto';
+import { TestNotificationDto, CreateTemplateDto, UpdateTemplateDto } from './notifications.dto';
import { EmailClientService } from './email-client.service';
import { SmsClientService } from './sms-client.service';
import { SendEmail } from './dtos/email.dto';
@@ -21,6 +21,39 @@ export class NotificationsController {
private smsClient: SmsClientService,
) {}
+ // --- Template management (declared before the ':passengerId' catch-all so the
+ // static 'templates' segment isn't captured as a passenger id) ---
+
+ @Get('templates')
+ @PassengerStaff([PASSENGER_PERMS.notifications.send, PASSENGER_PERMS.admin])
+ @ApiOperation({ summary: 'List notification templates' })
+ listTemplates() {
+ return this.service.listTemplates();
+ }
+
+ @Get('templates/:id')
+ @PassengerStaff([PASSENGER_PERMS.notifications.send, PASSENGER_PERMS.admin])
+ @ApiOperation({ summary: 'Get a notification template' })
+ getTemplate(@Param('id') id: string) {
+ return this.service.getTemplate(id);
+ }
+
+ @Post('templates')
+ @PassengerStaff([PASSENGER_PERMS.notifications.send, PASSENGER_PERMS.admin])
+ @ApiOperation({ summary: 'Create a notification template' })
+ @ApiBody({ type: CreateTemplateDto })
+ createTemplate(@Body() dto: CreateTemplateDto) {
+ return this.service.createTemplate(dto);
+ }
+
+ @Patch('templates/:id')
+ @PassengerStaff([PASSENGER_PERMS.notifications.send, PASSENGER_PERMS.admin])
+ @ApiOperation({ summary: 'Update a notification template (code is immutable)' })
+ @ApiBody({ type: UpdateTemplateDto })
+ updateTemplate(@Param('id') id: string, @Body() dto: UpdateTemplateDto) {
+ return this.service.updateTemplate(id, dto);
+ }
+
@Get(':passengerId')
@ApiOperation({ summary: 'Get notifications for passenger' })
getForPassenger(@Param('passengerId') id: string) {
diff --git a/apps/edr-passenger-api/src/modules/notifications/notifications.dto.ts b/apps/edr-passenger-api/src/modules/notifications/notifications.dto.ts
index e55535a2a..4b2050de1 100644
--- a/apps/edr-passenger-api/src/modules/notifications/notifications.dto.ts
+++ b/apps/edr-passenger-api/src/modules/notifications/notifications.dto.ts
@@ -1,6 +1,10 @@
-import { IsString, IsEnum, IsOptional, IsArray } from 'class-validator';
+import { IsString, IsEnum, IsOptional, IsArray, IsBoolean, Matches } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
+/** A single channel, or a comma-separated list of them (e.g. "SMS,EMAIL"). */
+const CHANNEL_LIST_RE = /^(EMAIL|SMS|PUSH|IN_APP)(,(EMAIL|SMS|PUSH|IN_APP))*$/;
+const CHANNEL_MSG = 'channel must be a comma-separated list of EMAIL, SMS, PUSH, IN_APP';
+
export enum NotificationCategoryEnum {
BOOKING = 'BOOKING',
PAYMENT = 'PAYMENT',
@@ -18,6 +22,55 @@ export class SendNotificationDto {
@ApiPropertyOptional() @IsOptional() metadata?: Record;
}
+export class CreateTemplateDto {
+ @ApiProperty({ example: 'booking.created', description: 'Unique template code / event key' })
+ @IsString()
+ code: string;
+
+ @ApiProperty({ example: 'SMS,EMAIL', description: 'Comma-separated channels: EMAIL, SMS, PUSH, IN_APP' })
+ @IsString()
+ @Matches(CHANNEL_LIST_RE, { message: CHANNEL_MSG })
+ channel: string;
+
+ @ApiPropertyOptional({ example: 'Your train ticket is booked' })
+ @IsOptional()
+ @IsString()
+ subject?: string;
+
+ @ApiProperty({ example: 'Dear {{passengerName}}, your booking {{bookingRef}} is booked.' })
+ @IsString()
+ bodyTemplate: string;
+
+ @ApiPropertyOptional({ example: true, description: 'Defaults to true' })
+ @IsOptional()
+ @IsBoolean()
+ active?: boolean;
+}
+
+// `code` is intentionally omitted — it is the immutable event key and cannot be changed.
+export class UpdateTemplateDto {
+ @ApiPropertyOptional({ example: 'SMS,EMAIL' })
+ @IsOptional()
+ @IsString()
+ @Matches(CHANNEL_LIST_RE, { message: CHANNEL_MSG })
+ channel?: string;
+
+ @ApiPropertyOptional({ example: 'Your train ticket is booked' })
+ @IsOptional()
+ @IsString()
+ subject?: string;
+
+ @ApiPropertyOptional({ example: 'Dear {{passengerName}}, ...' })
+ @IsOptional()
+ @IsString()
+ bodyTemplate?: string;
+
+ @ApiPropertyOptional({ example: true })
+ @IsOptional()
+ @IsBoolean()
+ active?: boolean;
+}
+
export class TestNotificationDto {
@ApiProperty({ example: 'booking.created' })
@IsString()
diff --git a/apps/edr-passenger-api/src/modules/notifications/notifications.service.ts b/apps/edr-passenger-api/src/modules/notifications/notifications.service.ts
index 43d1e4d46..fc8bffa27 100644
--- a/apps/edr-passenger-api/src/modules/notifications/notifications.service.ts
+++ b/apps/edr-passenger-api/src/modules/notifications/notifications.service.ts
@@ -1,4 +1,4 @@
-import { Injectable, Logger } from '@nestjs/common';
+import { Injectable, Logger, NotFoundException, ConflictException } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
@@ -6,6 +6,7 @@ import { PrismaService } from '../../common/prisma.service';
import { PushAdapter, NotificationChannel } from './notification.adapters';
import { EmailClientService } from './email-client.service';
import { SmsClientService } from './sms-client.service';
+import { CreateTemplateDto, UpdateTemplateDto } from './notifications.dto';
export type NotificationChannelType = 'EMAIL' | 'SMS' | 'PUSH' | 'IN_APP';
@@ -245,22 +246,151 @@ export class NotificationsService {
return { updated: true };
}
+ // ---------------------------------------------------------------------------
+ // Template management (backoffice). Templates are keyed by `code`; event
+ // handlers look them up by that code (e.g. 'booking.created'), so `code` is
+ // immutable once created — only channel/subject/body/active are editable.
+ // ---------------------------------------------------------------------------
+
+ listTemplates() {
+ return this.prisma.notificationTemplate.findMany({ orderBy: { code: 'asc' } });
+ }
+
+ async getTemplate(id: string) {
+ const template = await this.prisma.notificationTemplate.findUnique({ where: { id } });
+ if (!template) throw new NotFoundException(`Notification template ${id} not found`);
+ return template;
+ }
+
+ async createTemplate(dto: CreateTemplateDto) {
+ const existing = await this.prisma.notificationTemplate.findUnique({ where: { code: dto.code } });
+ if (existing) throw new ConflictException(`Template with code "${dto.code}" already exists`);
+ return this.prisma.notificationTemplate.create({
+ data: {
+ code: dto.code,
+ channel: dto.channel,
+ subject: dto.subject ?? null,
+ bodyTemplate: dto.bodyTemplate,
+ active: dto.active ?? true,
+ },
+ });
+ }
+
+ async updateTemplate(id: string, dto: UpdateTemplateDto) {
+ await this.getTemplate(id); // 404 if missing
+ return this.prisma.notificationTemplate.update({
+ where: { id },
+ data: {
+ ...(dto.channel !== undefined ? { channel: dto.channel } : {}),
+ ...(dto.subject !== undefined ? { subject: dto.subject } : {}),
+ ...(dto.bodyTemplate !== undefined ? { bodyTemplate: dto.bodyTemplate } : {}),
+ ...(dto.active !== undefined ? { active: dto.active } : {}),
+ },
+ });
+ }
+
+ /**
+ * Booking created (awaiting payment) → the rich "your ticket is booked, here is the pay link"
+ * message. Mirrors the operator's legacy SMS: greeting, route, train/seat line(s), travel
+ * times, pay link, and the 2-hour pay-window warning (enforced by tasks.service — see
+ * MAX_PAYMENT_HOURS). The body comes from the editable `booking.created` template; the shallow
+ * event payload is re-fetched with schedule + seats to fill it.
+ */
@OnEvent('booking.created')
async onBookingCreated(payload: any) {
- const booking = payload.booking;
- await this.send(
- 'booking.created',
- booking.passengerId,
- {
- bookingRef: booking.bookingRef,
- amount: this.formatAmount(booking),
- currency: booking.displayCurrency ?? 'ETB',
- category: 'BOOKING',
- deepLink: `edr://bookings/${booking.bookingRef}`,
+ const bookingId = payload.booking.id;
+
+ const booking = await this.prisma.booking.findUnique({
+ where: { id: bookingId },
+ include: {
+ schedule: { include: { originStation: true, destinationStation: true, train: true } },
+ seats: { include: { seat: { include: { coach: { include: { coachType: true } } } } } },
},
- // For now, always notify the travelling passenger on every channel.
- ['IN_APP', 'EMAIL', 'SMS'],
- );
+ });
+
+ const ref = booking?.bookingRef ?? payload.booking.bookingRef;
+ const passengerId = booking?.passengerId ?? payload.booking.passengerId;
+
+ const template = await this.prisma.notificationTemplate.findUnique({
+ where: { code: 'booking.created' },
+ });
+ if (!template || !template.active) {
+ this.logger.warn('booking.created template not found or inactive');
+ return;
+ }
+
+ const { subject, body } = this.interpolate(template, this.buildBookingCreatedContext(booking, ref));
+
+ // IN_APP — always created.
+ await this.createInAppNotification(passengerId, subject, body, {
+ category: 'BOOKING',
+ deepLink: `edr://bookings/${ref}`,
+ });
+
+ // SMS — the primary channel for this message. Prefer the IAM user's number, fall back to
+ // the phone entered on the booking form (guest bookings have no IAM user).
+ const contactPhone: string | null =
+ (booking as any)?.contactPhone ?? (payload.booking as any)?.contactPhone ?? null;
+ const iamPhone = passengerId ? await this.getRecipientAddress(passengerId, 'SMS').catch(() => null) : null;
+ const smsPhone = iamPhone ?? contactPhone;
+ if (smsPhone) {
+ await this.smsClient
+ .sendSms({ to: smsPhone, message: body })
+ .catch((e) => this.logger.error(`booking.created SMS failed for ${ref}: ${e?.message}`));
+ } else {
+ this.logger.warn(`No SMS phone for booking ${ref}`);
+ }
+
+ // EMAIL — same text, with the same contact fallback.
+ const contactEmail: string | null =
+ (booking as any)?.contactEmail ?? (payload.booking as any)?.contactEmail ?? null;
+ const iamEmail = passengerId ? await this.getRecipientAddress(passengerId, 'EMAIL').catch(() => null) : null;
+ const emailTo = iamEmail ?? contactEmail;
+ if (emailTo) {
+ await this.emailClient
+ .sendEmail({ to: emailTo, subject, text: body })
+ .catch((e) => this.logger.error(`booking.created email failed for ${ref}: ${e?.message}`));
+ }
+ }
+
+ /**
+ * Builds the interpolation context for the `booking.created` template. `trainSeatLines` is a
+ * pre-joined block of one "Train/Seat: …" line per booked seat (multi-passenger bookings get
+ * several lines).
+ */
+ private buildBookingCreatedContext(booking: any, ref: string): Record {
+ const s = booking?.schedule ?? {};
+ const trainName = s.train?.name ?? s.train?.number ?? '';
+ const fmtDate = (d: any) =>
+ d ? new Date(d).toLocaleDateString('en-US', { month: 'short', day: '2-digit', year: 'numeric' }) : 'TBD';
+ const fmtTime = (d: any) =>
+ d ? new Date(d).toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: true }) : 'TBD';
+
+ const seats = booking?.seats ?? [];
+ const trainSeatLines = seats
+ .map((bs: any) => {
+ const coach = bs.seat?.coach?.number ?? '-';
+ const cls = bs.seat?.coach?.coachType?.name ?? '';
+ const seatNo = bs.seat?.seatNumber ?? '-';
+ return `Train/Seat: Train ${trainName}, ${coach} ${cls}, seat no. ${seatNo}`.replace(/ +/g, ' ').trim();
+ })
+ .join('\n');
+
+ // Lead passenger (leg-1 seat). Booking has no contactName; the traveller name lives on the seat.
+ const passengerName = seats[0]?.passengerName ?? 'Passenger';
+ const payLink = `${process.env.PORTAL_URL ?? 'http://localhost:5174'}/booking/detail?ref=${ref}`;
+
+ return {
+ passengerName,
+ bookingRef: ref,
+ origin: s.originStation?.name ?? '',
+ destination: s.destinationStation?.name ?? '',
+ trainSeatLines,
+ travelDate: fmtDate(s.departureAt),
+ departureTime: fmtTime(s.departureAt),
+ arrivalTime: fmtTime(s.arrivalAt),
+ payLink,
+ };
}
/**
diff --git a/apps/edr-passenger-api/src/modules/payments/payments.service.spec.ts b/apps/edr-passenger-api/src/modules/payments/payments.service.spec.ts
index 112cc5096..bc5faee95 100644
--- a/apps/edr-passenger-api/src/modules/payments/payments.service.spec.ts
+++ b/apps/edr-passenger-api/src/modules/payments/payments.service.spec.ts
@@ -76,7 +76,7 @@ describe("PaymentsService", () => {
// Mirrors the real ETB→major conversion: minor units → major price (TELEBIRR settles in ETB).
const mockCurrencyService = {
convertEtbMinorToChargeMajor: jest.fn((minor: number) =>
- Promise.resolve(minor / 100),
+ Promise.resolve(minor),
),
getRateOrThrow: jest.fn(),
};
diff --git a/apps/edr-passenger-api/src/modules/payments/payments.service.ts b/apps/edr-passenger-api/src/modules/payments/payments.service.ts
index 9e43139c4..5e44038c4 100644
--- a/apps/edr-passenger-api/src/modules/payments/payments.service.ts
+++ b/apps/edr-passenger-api/src/modules/payments/payments.service.ts
@@ -338,7 +338,14 @@ export class PaymentsService {
};
return this.prisma.paymentIntent.upsert({
where: { bookingId },
- update: data,
+ // amountMinor/currency are refreshed on update too: a cross-currency method switch
+ // (e.g. Waafi/USD → Telebirr/ETB) re-initiates over the same row, and the projection
+ // must reflect the currency the new provider actually charges — not the first one's.
+ update: {
+ ...data,
+ amountMinor: snapshot.amountMinor,
+ currency: snapshot.currency,
+ },
create: {
bookingId,
amountMinor: snapshot.amountMinor,
@@ -550,7 +557,6 @@ export class PaymentsService {
getSupportedPaymentMethods(region?: PaymentRegionEnum) {
return this.prisma.paymentMethod.findMany({
where: {
- enabled: true,
...(region
? {
region: {
diff --git a/apps/edr-passenger-api/src/modules/search/search.module.ts b/apps/edr-passenger-api/src/modules/search/search.module.ts
index baadcf90c..b7788c2fe 100644
--- a/apps/edr-passenger-api/src/modules/search/search.module.ts
+++ b/apps/edr-passenger-api/src/modules/search/search.module.ts
@@ -4,9 +4,10 @@ import { SearchService } from './search.service';
import { CurrencyModule } from '../currency/currency.module';
import { FareEngineModule } from '../fare-engine/fare-engine.module';
import { SegmentsModule } from '../segments/segments.module';
+import { SystemConfigModule } from '../system-config/system-config.module';
@Module({
- imports: [CurrencyModule, FareEngineModule, SegmentsModule],
+ imports: [CurrencyModule, FareEngineModule, SegmentsModule, SystemConfigModule],
controllers: [SearchController],
providers: [SearchService],
exports: [SearchService],
diff --git a/apps/edr-passenger-api/src/modules/search/search.service.ts b/apps/edr-passenger-api/src/modules/search/search.service.ts
index 355446544..4de0f0d21 100644
--- a/apps/edr-passenger-api/src/modules/search/search.service.ts
+++ b/apps/edr-passenger-api/src/modules/search/search.service.ts
@@ -6,6 +6,7 @@ import { FareEngineService } from '../fare-engine/fare-engine.service';
import { SegmentsService } from '../segments/segments.service';
import { resolveCurrencyFromNationality } from '../fare-engine/fare-engine.dto';
import { Currency } from '@prisma/client';
+import { SystemConfigService, CONFIG_KEYS } from '../system-config/system-config.service';
const POINTS_TO_MINOR = 10;
@@ -40,8 +41,13 @@ export class SearchService {
private currencyService: CurrencyService,
private fareEngine: FareEngineService,
private segmentsService: SegmentsService,
+ private systemConfig: SystemConfigService,
) {}
+ private async getCutoffHours(): Promise {
+ return this.systemConfig.getNumber(CONFIG_KEYS.HOLD_CUTOFF_HOURS_BEFORE_DEPARTURE);
+ }
+
async searchTrips(dto: SearchTripsDto) {
const [direct, transit] = await Promise.all([
this.searchSchedules(
@@ -166,13 +172,14 @@ export class SearchService {
const schedules = await this.prisma.trainSchedule.findMany({
where: {
- status: { in: ['SCHEDULED', 'BOARDING'] },
+ status: 'SCHEDULED',
isPackageOnly: false,
OR: [
{ departureAt: { gte: windowStart, lt: requestedDate } },
{ departureAt: { gte: requestedNextDay < now ? now : requestedNextDay, lt: windowEnd } },
],
stopTimes: { some: { stationId: originStationId } },
+ coachAssignments: { some: {} },
},
include: SCHEDULE_INCLUDE,
orderBy: { departureAt: 'asc' },
@@ -183,7 +190,7 @@ export class SearchService {
this.buildScheduleResult(schedule as any, originStationId, destinationStationId, totalPassengers, nationality)
)
);
- return results.filter(Boolean);
+ return results.filter((r): r is NonNullable => !!r && r.hasAvailability);
}
private async searchSchedules(
@@ -200,12 +207,18 @@ export class SearchService {
const now = new Date();
const totalPassengers = adultCount + (childCount ?? 0);
+ const cutoffHours = await this.getCutoffHours();
+ const cutoffThreshold = new Date(now.getTime() + cutoffHours * 60 * 60 * 1000);
+ const isToday = now.getFullYear() === y && now.getMonth() === m - 1 && now.getDate() === d;
+ const earliest = isToday ? cutoffThreshold : date;
+
const schedules = await this.prisma.trainSchedule.findMany({
where: {
- status: { in: ['SCHEDULED', 'BOARDING'] },
+ status: 'SCHEDULED',
isPackageOnly: false,
- departureAt: { gte: date < now ? now : date, lt: nextDay },
+ departureAt: { gte: earliest, lt: nextDay },
stopTimes: { some: { stationId: originStationId } },
+ coachAssignments: { some: {} },
},
include: SCHEDULE_INCLUDE,
});
@@ -215,7 +228,7 @@ export class SearchService {
this.buildScheduleResult(schedule as any, originStationId, destinationStationId, totalPassengers, nationality)
)
);
- return results.filter(Boolean);
+ return results.filter((r): r is NonNullable => !!r && r.hasAvailability);
}
// ── Transit search ─────────────────────────────────────────────────────────
@@ -241,26 +254,31 @@ export class SearchService {
const [leg1Schedules, allCandidates] = await Promise.all([
this.prisma.trainSchedule.findMany({
where: {
- status: { in: ['SCHEDULED', 'BOARDING'] },
+ status: 'SCHEDULED',
isPackageOnly: false,
departureAt: { gte: dayStart, lt: dayEnd },
stopTimes: { some: { stationId: originStationId } },
+ coachAssignments: { some: {} },
},
include: SCHEDULE_INCLUDE,
}),
this.prisma.trainSchedule.findMany({
where: {
- status: { in: ['SCHEDULED', 'BOARDING'] },
+ status: 'SCHEDULED',
isPackageOnly: false,
departureAt: { gte: dayStart, lt: leg2WindowEnd },
+ coachAssignments: { some: {} },
},
include: SCHEDULE_INCLUDE,
}),
]);
+ const cutoffHours = await this.getCutoffHours();
+ const cutoffThreshold = new Date(Date.now() + cutoffHours * 60 * 60 * 1000);
+
const results: any[] = [];
- for (const leg1 of leg1Schedules as ScheduleWithIncludes[]) {
+ for (const leg1 of (leg1Schedules as ScheduleWithIncludes[]).filter(s => new Date(s.departureAt) > cutoffThreshold)) {
const originStop = leg1.stopTimes.find(s => s.stationId === originStationId);
if (!originStop) continue;
@@ -354,6 +372,9 @@ export class SearchService {
.map((s: any) => s.id as string)
);
+ // Exclude schedules with no seats at all
+ if (allValidSeatIds.length === 0) return null;
+
// Run availability batch and fare calculation in parallel
const [freeSeats, faresByClass] = await Promise.all([
this.segmentsService.getFreeSeatIds(
diff --git a/apps/edr-passenger-api/src/modules/seat-classes/seat-classes.dto.ts b/apps/edr-passenger-api/src/modules/seat-classes/seat-classes.dto.ts
index d12fe0fb6..1f061bf53 100644
--- a/apps/edr-passenger-api/src/modules/seat-classes/seat-classes.dto.ts
+++ b/apps/edr-passenger-api/src/modules/seat-classes/seat-classes.dto.ts
@@ -1,17 +1,31 @@
-import { IsString, IsInt, IsBoolean, IsOptional } from 'class-validator';
+import { IsString, IsInt, IsBoolean, IsOptional, IsIn } from 'class-validator';
import { ApiProperty, ApiPropertyOptional, PartialType } from '@nestjs/swagger';
export class CreateSeatClassDto {
+ @ApiProperty()
+ @IsString()
+ coachTypeId: string;
+
@ApiProperty({ example: 'Economy Seat' })
@IsString()
name: string;
- @ApiPropertyOptional({ example: 'Standard economy seating' })
+ @ApiPropertyOptional()
@IsOptional()
@IsString()
description?: string;
- @ApiProperty({ example: 45000, description: 'Base price in minor currency units' })
+ @ApiPropertyOptional({ enum: ['LOCAL', 'INTERNATIONAL'] })
+ @IsOptional()
+ @IsIn(['LOCAL', 'INTERNATIONAL'])
+ nationalityType?: string;
+
+ @ApiPropertyOptional({ enum: ['UPPER', 'MIDDLE', 'LOWER'] })
+ @IsOptional()
+ @IsString()
+ bedPosition?: string;
+
+ @ApiProperty({ example: 3000, description: 'Per-km rate in minor units (tariff decimal × 100000)' })
@IsInt()
basePrice: number;
diff --git a/apps/edr-passenger-api/src/modules/support/support.controller.ts b/apps/edr-passenger-api/src/modules/support/support.controller.ts
index 63adefe5a..4404c3ea8 100644
--- a/apps/edr-passenger-api/src/modules/support/support.controller.ts
+++ b/apps/edr-passenger-api/src/modules/support/support.controller.ts
@@ -17,6 +17,8 @@ import { JwtGuard } from '../../common/jwt.guard';
import {
CreateConversationDto,
CreateGuestConversationDto,
+ DeviceIdBodyDto,
+ DeviceSendMessageDto,
GuestIdBodyDto,
GuestSendMessageDto,
ListConversationsQueryDto,
@@ -103,7 +105,39 @@ export class SupportController {
return this.service.unreadCount('USER', { iamUserId: userId(req) });
}
- // ---- customer: guest (unauthenticated) --------------------------------
+ // ---- customer: device-scoped single thread (portal) -------------------
+ // No auth, no forms. One conversation per device id (localStorage). Anyone
+ // with the device id can see that thread — accepted MVP trade-off.
+
+ @Get('device/thread')
+ @IsPublic()
+ @ApiOperation({ summary: "Get the device's support thread + messages" })
+ deviceThread(@Query('deviceId') deviceId: string) {
+ return this.service.getDeviceThread(deviceId);
+ }
+
+ @Post('device/messages')
+ @IsPublic()
+ @ApiOperation({ summary: 'Send a message (creates the thread on first send)' })
+ deviceSend(@Body() body: DeviceSendMessageDto) {
+ return this.service.sendDeviceMessage(body.deviceId, body.text);
+ }
+
+ @Post('device/read')
+ @IsPublic()
+ @ApiOperation({ summary: 'Mark the device thread read' })
+ deviceRead(@Body() body: DeviceIdBodyDto) {
+ return this.service.markDeviceRead(body.deviceId);
+ }
+
+ @Get('device/unread-count')
+ @IsPublic()
+ @ApiOperation({ summary: "Count the device thread's unread messages" })
+ deviceUnread(@Query('deviceId') deviceId: string) {
+ return this.service.unreadCount('USER', { guestId: deviceId });
+ }
+
+ // ---- customer: guest (unauthenticated, multi-ticket) ------------------
// No JwtGuard. Access is scoped by a client-generated `guestId` (the bearer
// of access — anyone with it sees that thread; accepted MVP trade-off).
diff --git a/apps/edr-passenger-api/src/modules/support/support.dto.ts b/apps/edr-passenger-api/src/modules/support/support.dto.ts
index 77a3ee3ed..a60ac607e 100644
--- a/apps/edr-passenger-api/src/modules/support/support.dto.ts
+++ b/apps/edr-passenger-api/src/modules/support/support.dto.ts
@@ -93,6 +93,26 @@ export class GuestIdBodyDto {
guestId!: string;
}
+export class DeviceSendMessageDto {
+ @ApiProperty({ description: 'Client device id (localStorage).' })
+ @IsString()
+ @Length(8, 120)
+ deviceId!: string;
+
+ @ApiProperty({ description: 'Message text.' })
+ @IsString()
+ @MinLength(1)
+ @MaxLength(4000)
+ text!: string;
+}
+
+export class DeviceIdBodyDto {
+ @ApiProperty()
+ @IsString()
+ @Length(8, 120)
+ deviceId!: string;
+}
+
export class UpdateStatusDto {
@ApiProperty({ enum: SupportStatusDto })
@IsEnum(SupportStatusDto)
diff --git a/apps/edr-passenger-api/src/modules/support/support.gateway.ts b/apps/edr-passenger-api/src/modules/support/support.gateway.ts
index 203c39035..c5b65497b 100644
--- a/apps/edr-passenger-api/src/modules/support/support.gateway.ts
+++ b/apps/edr-passenger-api/src/modules/support/support.gateway.ts
@@ -7,7 +7,6 @@ import {
import { Server, Socket } from 'socket.io';
import { Passenger as PassengerTypes } from '@edr/types';
-import { PrismaService } from '../../common/prisma.service';
import { WsAuthService } from './ws-auth.service';
/**
@@ -34,27 +33,24 @@ export class SupportGateway implements OnGatewayConnection {
@WebSocketServer()
private readonly server!: Server;
- constructor(
- private readonly wsAuth: WsAuthService,
- private readonly prisma: PrismaService,
- ) {}
+ constructor(private readonly wsAuth: WsAuthService) {}
async handleConnection(socket: Socket): Promise {
const userId = await this.wsAuth.resolveUserId(this.extractToken(socket));
- // Authenticated: passenger (own room) or backoffice staff (shared room).
+ // A valid token means a backoffice agent: the portal connects only with a
+ // device/guest id (never a token), so every token-authed socket is staff.
+ // Join the shared backoffice room — no passenger-row heuristic needed.
if (userId) {
socket.data.userId = userId;
- const passenger = await this.prisma.passenger.findUnique({
- where: { iamUserId: userId },
+ socket.data.side = 'AGENT';
+ await socket.join(SupportGateway.BACKOFFICE_ROOM);
+ socket.emit('support:hello', {
+ side: 'AGENT',
+ room: SupportGateway.BACKOFFICE_ROOM,
+ userId,
});
- if (passenger) {
- await socket.join(`user:${userId}`);
- socket.data.side = 'USER';
- } else {
- await socket.join(SupportGateway.BACKOFFICE_ROOM);
- socket.data.side = 'AGENT';
- }
+ this.logger.debug(`support socket ${socket.id} → AGENT (backoffice)`);
return;
}
diff --git a/apps/edr-passenger-api/src/modules/support/support.service.ts b/apps/edr-passenger-api/src/modules/support/support.service.ts
index dfbbb3b12..77f701bf2 100644
--- a/apps/edr-passenger-api/src/modules/support/support.service.ts
+++ b/apps/edr-passenger-api/src/modules/support/support.service.ts
@@ -113,6 +113,61 @@ export class SupportService {
return this.firstMessage(conversation, input.initialMessage);
}
+ // ---- customer: device-scoped single thread (portal) -------------------
+
+ /** The device's single conversation + its messages ({conversation:null} if none). */
+ async getDeviceThread(deviceId: string): Promise {
+ if (!deviceId) return { conversation: null, messages: [] };
+ const c = (await this.prisma.supportConversation.findFirst({
+ where: { guestId: deviceId },
+ orderBy: { createdAt: 'asc' },
+ })) as ConversationRow | null;
+ if (!c) return { conversation: null, messages: [] };
+ const rows = await this.prisma.supportMessage.findMany({
+ where: { conversationId: c.id },
+ orderBy: { createdAt: 'asc' },
+ });
+ const unread = await this.computeUnread([c], 'USER');
+ return {
+ conversation: this.toConversationDto(c, unread.get(c.id) ?? 0),
+ messages: rows.map((m) => this.toMessageDto(m)),
+ };
+ }
+
+ /** Append a message to the device's thread, creating it on first message. */
+ async sendDeviceMessage(
+ deviceId: string,
+ text: string,
+ ): Promise {
+ let c = (await this.prisma.supportConversation.findFirst({
+ where: { guestId: deviceId },
+ orderBy: { createdAt: 'asc' },
+ })) as ConversationRow | null;
+ if (!c) {
+ c = (await this.prisma.supportConversation.create({
+ data: { guestId: deviceId, subject: 'Support chat', status: 'OPEN' },
+ })) as ConversationRow;
+ }
+ const updated = await this.appendMessage(c, 'USER', text);
+ const last = updated.messages[updated.messages.length - 1];
+ return this.toMessageDto(last);
+ }
+
+ /** Mark the device's thread read (customer side). */
+ async markDeviceRead(deviceId: string): Promise<{ unreadCount: number }> {
+ const c = await this.prisma.supportConversation.findFirst({
+ where: { guestId: deviceId },
+ orderBy: { createdAt: 'asc' },
+ });
+ if (c) {
+ await this.prisma.supportConversation.update({
+ where: { id: c.id },
+ data: { userLastReadAt: new Date() },
+ });
+ }
+ return this.unreadCount('USER', { guestId: deviceId });
+ }
+
async listForCustomer(
owner: CustomerOwner,
query: ListQuery,
diff --git a/apps/edr-passenger-api/src/modules/tickets/tickets.service.spec.ts b/apps/edr-passenger-api/src/modules/tickets/tickets.service.spec.ts
index 7f6ad7f52..96b3e2ef8 100644
--- a/apps/edr-passenger-api/src/modules/tickets/tickets.service.spec.ts
+++ b/apps/edr-passenger-api/src/modules/tickets/tickets.service.spec.ts
@@ -72,9 +72,8 @@ describe('TicketsService - Offline Validation', () => {
const result = await service.validateOfflineBatch(validations);
- expect(result.success).toBe(1);
+ expect(result.successful).toBe(1);
expect(result.failed).toBe(0);
- expect(result.duplicate).toBe(0);
});
it('should detect duplicate validations', async () => {
@@ -98,8 +97,8 @@ describe('TicketsService - Offline Validation', () => {
const result = await service.validateOfflineBatch(validations);
- expect(result.success).toBe(1);
- expect(result.duplicate).toBe(1);
+ expect(result.successful).toBe(1);
+ expect(result.failed).toBe(1);
});
it('should handle already validated tickets', async () => {
@@ -119,8 +118,8 @@ describe('TicketsService - Offline Validation', () => {
const result = await service.validateOfflineBatch(validations);
- expect(result.duplicate).toBe(1);
- expect(result.success).toBe(0);
+ expect(result.successful).toBe(0);
+ expect(result.failed).toBe(1);
});
});
});
diff --git a/apps/edr-passenger-web/backoffice/public/docs.md b/apps/edr-passenger-web/backoffice/public/docs.md
index 0a4922fa9..428c365ae 100644
--- a/apps/edr-passenger-web/backoffice/public/docs.md
+++ b/apps/edr-passenger-web/backoffice/public/docs.md
@@ -34,7 +34,7 @@ The Passenger Backoffice Application is a comprehensive management system for th
- **Live Tracking**: Monitor trip status and real-time updates
- **Security Monitoring**: Fraud detection and audit logging
- **Comprehensive Analytics**: Revenue, occupancy, and performance reports
-- **🆕 Excess Baggage Management**: Handle boarding baggage charges with agent tools
+- **🆕 Excess Luggage Management**: Handle boarding baggage charges with agent tools
- **🆕 Travel Packages**: Manage pilgrimage and group travel packages with tiered pricing
- **🆕 System Health Monitoring**: Real-time API health checks and system status
- **🆕 Advanced Fare Configuration**: Dynamic pricing with segment-based rules
@@ -94,7 +94,7 @@ The application is organized into 8 main sections:
├── System Config
└── Settings
└── Enhanced Features
- ├── Excess Baggage
+ ├── Excess Luggage
├── Travel Packages
├── Package Inquiries
├── Health Monitoring
@@ -2862,7 +2862,7 @@ Action: Block user
### Version 1.0.0 (January 15, 2026)
- **Complete Platform Release** - Full-featured passenger management system
-- **Excess Baggage Management** - Complete boarding baggage handling with agent tools and passenger self-pay options
+- **Excess Luggage Management** - Complete boarding baggage handling with agent tools and passenger self-pay options
- **Travel Packages** - Pilgrimage and group travel packages with tiered pricing, capacity management, and inquiry handling
- **System Health Monitoring** - Real-time API health checks with liveness, readiness, and performance metrics
- **System Configuration** - Centralized config management with feature flags, rate limiting, and operational controls
@@ -2920,7 +2920,7 @@ Action: Block user
## Enhanced Features
-### Excess Baggage
+### Excess Luggage
**Purpose**: Manage excess baggage charges at boarding with agent tools and passenger self-pay
**Access Level**: Agent, Supervisor, Admin
@@ -2941,7 +2941,7 @@ Action: Block user
└──────────────────────────────┘
```
-#### Excess Baggage Process
+#### Excess Luggage Process
1. **At Boarding**: Agent weighs passenger baggage
2. **If Excess**: Agent creates charge in system
@@ -2962,8 +2962,8 @@ Action: Block user
##### READ (List Charges)
-1. **Access Excess Baggage Page**:
- - Click **Excess Baggage** in Enhanced Features section
+1. **Access Excess Luggage Page**:
+ - Click **Excess Luggage** in Enhanced Features section
- Shows all baggage charges
2. **Search & Filter**:
@@ -3000,7 +3000,7 @@ Action: Block user
#### Agent Workflow
-1. **Weigh Baggage**: Use station scales
+1. **Weigh Luggage**: Use station scales
2. **Check Allowance**: Compare to passenger's seat class allowance
3. **Create Charge**: If excess weight found
4. **Offer Payment Options**:
diff --git a/apps/edr-passenger-web/backoffice/src/app/classes/page.tsx b/apps/edr-passenger-web/backoffice/src/app/classes/page.tsx
index 348268ca2..7cc9875dc 100644
--- a/apps/edr-passenger-web/backoffice/src/app/classes/page.tsx
+++ b/apps/edr-passenger-web/backoffice/src/app/classes/page.tsx
@@ -311,42 +311,21 @@ export default function ClassesPage() {
Per-km distance-based fare rate
-
-
-
Premium Fee (ETB)
-
-
Flat fee per passenger (e.g., lounge access, extra legroom)
-
-
-
-
Insurance Fee (ETB)
-
-
Flat fee per passenger (e.g., travel insurance)
-
+
+
+
Insurance Fee (ETB)
+
+
Flat fee per passenger (e.g., travel insurance)
-
-
Total Fare Calculation:
-
Total = (Base Fare × Distance) + Premium + Insurance
-
• Premium applies per passenger
-
• Insurance applies per passenger
-
diff --git a/apps/edr-passenger-web/backoffice/src/app/currencies/page.tsx b/apps/edr-passenger-web/backoffice/src/app/currencies/page.tsx
index 4cf393713..cee6944f6 100644
--- a/apps/edr-passenger-web/backoffice/src/app/currencies/page.tsx
+++ b/apps/edr-passenger-web/backoffice/src/app/currencies/page.tsx
@@ -220,13 +220,6 @@ export default function CurrenciesPage() {
)}
-
-
How it works
-
• ETB is the transaction currency — all fares are stored in ETB minor units (1 ETB = 100 minor)
-
• DJF and USD rates are used to display prices to passengers in their preferred currency
-
• Rates apply globally; changes take effect immediately on the next booking or fare quote
-
-
{ setShowAddModal(false); setError(null); }}
diff --git a/apps/edr-passenger-web/backoffice/src/app/docs/sections/OperationsSection.tsx b/apps/edr-passenger-web/backoffice/src/app/docs/sections/OperationsSection.tsx
index 7c14cc357..0f9b9beb5 100644
--- a/apps/edr-passenger-web/backoffice/src/app/docs/sections/OperationsSection.tsx
+++ b/apps/edr-passenger-web/backoffice/src/app/docs/sections/OperationsSection.tsx
@@ -82,7 +82,7 @@ export default function OperationsSection() {
{/* EXCESS BAGGAGE */}
-
📦 Luggage (Excess Baggage)
+
📦 Luggage (Excess Luggage)
Handle excess baggage charges at boarding — passenger self-pay or agent cash collection. Access level: Agent, Supervisor, Admin.
{['PENDING','PAID','CASH_COLLECTED','EXPIRED','WAIVED'].map(s => (
@@ -91,7 +91,7 @@ export default function OperationsSection() {
-
📦 How-To: Handle Excess Baggage
+
📦 How-To: Handle Excess Luggage
Click Luggage in Operations Search by booking reference; filter by status or date
diff --git a/apps/edr-passenger-web/backoffice/src/app/notifications/page.tsx b/apps/edr-passenger-web/backoffice/src/app/notifications/page.tsx
index 049a12237..d277eb3de 100644
--- a/apps/edr-passenger-web/backoffice/src/app/notifications/page.tsx
+++ b/apps/edr-passenger-web/backoffice/src/app/notifications/page.tsx
@@ -10,8 +10,12 @@ import ActionButton from '@/components/ui/ActionButton';
import { notificationsApi } from '@/lib/api';
import { formatDateTime } from '@/lib/utils';
+const CHANNEL_OPTIONS = ['EMAIL', 'SMS', 'PUSH', 'IN_APP'];
+
export default function NotificationsPage() {
const [showModal, setShowModal] = useState(false);
+ const [editing, setEditing] = useState(null);
+ const [templateError, setTemplateError] = useState(null);
const [activeTab, setActiveTab] = useState<'templates' | 'send' | 'history'>('templates');
const [sendForm, setSendForm] = useState({ recipientType: 'ALL', channel: 'EMAIL', subject: '', message: '' });
const [sendError, setSendError] = useState(null);
@@ -34,10 +38,54 @@ export default function NotificationsPage() {
mutationFn: notificationsApi.createTemplate,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['notification-templates'] });
- setShowModal(false);
+ closeTemplateModal();
},
+ onError: (e: any) => setTemplateError(e?.response?.data?.message || e?.message || 'Failed to create template'),
});
+ const updateTemplateMutation = useMutation({
+ mutationFn: ({ id, data }: { id: string; data: any }) => notificationsApi.updateTemplate(id, data),
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ['notification-templates'] });
+ closeTemplateModal();
+ },
+ onError: (e: any) => setTemplateError(e?.response?.data?.message || e?.message || 'Failed to update template'),
+ });
+
+ const openCreateModal = () => {
+ setEditing(null);
+ setTemplateError(null);
+ setShowModal(true);
+ };
+
+ const openEditModal = (template: any) => {
+ setEditing(template);
+ setTemplateError(null);
+ setShowModal(true);
+ };
+
+ const closeTemplateModal = () => {
+ setShowModal(false);
+ setEditing(null);
+ setTemplateError(null);
+ };
+
+ const handleTemplateSubmit = async (e: React.FormEvent) => {
+ e.preventDefault();
+ setTemplateError(null);
+ const fd = new FormData(e.currentTarget);
+ const channel = fd.get('channel') as string;
+ const subject = (fd.get('subject') as string) || undefined;
+ const bodyTemplate = fd.get('bodyTemplate') as string;
+ const active = fd.get('active') === 'on';
+
+ if (editing) {
+ await updateTemplateMutation.mutateAsync({ id: editing.id, data: { channel, subject, bodyTemplate, active } });
+ } else {
+ await createTemplateMutation.mutateAsync({ code: fd.get('code') as string, channel, subject, bodyTemplate, active });
+ }
+ };
+
const sendMutation = useMutation({
mutationFn: notificationsApi.send,
onSuccess: () => {
@@ -59,23 +107,36 @@ export default function NotificationsPage() {
const historyArray = Array.isArray(historyData) ? historyData : (historyData as any)?.items || [];
const templateColumns = [
- { key: 'name', label: 'Template Name', render: (t: any) => {t.name} },
- { key: 'channel', label: 'Channel', render: (t: any) => {t.channel || t.type} },
+ { key: 'code', label: 'Code / Event Key', render: (t: any) => {t.code} },
+ { key: 'channel', label: 'Channel', render: (t: any) => {t.channel} },
{
key: 'subject',
label: 'Subject / Body',
- render: (t: any) => {t.subject || t.body || t.content || '—'} ,
+ render: (t: any) => (
+
+ {t.subject ? `${t.subject} — ` : ''}{(t.bodyTemplate || '').replace(/\n/g, ' ') || '—'}
+
+ ),
},
{
key: 'active',
label: 'Status',
render: (t: any) => (
-
- {t.isActive !== false ? 'Active' : 'Inactive'}
+
+ {t.active !== false ? 'Active' : 'Inactive'}
),
},
{ key: 'createdAt', label: 'Created', render: (t: any) => {formatDateTime(t.createdAt)} },
+ {
+ key: 'actions',
+ label: '',
+ render: (t: any) => (
+ openEditModal(t)} className="text-sm font-medium text-primary hover:underline">
+ Edit
+
+ ),
+ },
];
const historyColumns = [
@@ -106,7 +167,7 @@ export default function NotificationsPage() {
Manage notification templates and send messages
{activeTab === 'templates' && (
-
setShowModal(true)}>New Template
+
New Template
)}
@@ -184,43 +245,68 @@ export default function NotificationsPage() {
)}
- setShowModal(false)} title="Create Notification Template">
- {
- e.preventDefault();
- const fd = new FormData(e.currentTarget);
- await createTemplateMutation.mutateAsync({
- name: fd.get('name') as string,
- channel: fd.get('channel') as string,
- subject: fd.get('subject') as string,
- body: fd.get('body') as string,
- });
- }}
- className="space-y-4"
- >
+
+
-
Template Name *
-
+
Code / Event Key {editing ? '' : '*'}
+
+ {editing && (
+
Code is the event key and cannot be changed.
+ )}
-
Channel
-
- Email
- SMS
- Push Notification
+ Channel *
+
+ {CHANNEL_OPTIONS.map((c) => (
+ {c}
+ ))}
+ SMS,EMAIL
+ SMS,EMAIL,IN_APP
+
+ One channel, or a comma-separated list (EMAIL, SMS, PUSH, IN_APP).
+
Subject
-
+
Body *
-
+
+
+ Use {'{{variable}}'} placeholders (e.g. {'{{passengerName}}'}, {'{{bookingRef}}'}) — they are filled in when the notification is sent.
+
+
+
+ Active
+
+ {templateError && {templateError}
}
-
setShowModal(false)}>Cancel
-
Create Template
+
Cancel
+
+ {editing ? 'Save Changes' : 'Create Template'}
+
diff --git a/apps/edr-passenger-web/backoffice/src/app/pricing/page.tsx b/apps/edr-passenger-web/backoffice/src/app/pricing/page.tsx
index 3e221839b..73ce87383 100644
--- a/apps/edr-passenger-web/backoffice/src/app/pricing/page.tsx
+++ b/apps/edr-passenger-web/backoffice/src/app/pricing/page.tsx
@@ -609,7 +609,7 @@ export default function PricingPage() {
className={`px-4 py-2 font-medium border-b-2 transition-colors ${tab === 'baggage' ? 'border-primary text-primary' : 'border-transparent text-muted-foreground'
}`}
>
- Excess Baggage Rates
+ Excess Luggage Rates
@@ -765,24 +765,6 @@ export default function PricingPage() {
-
-
Pricing Structure
-
-
- • Segment Fares: Set fares for specific stop-to-stop segments (e.g., Addis → Dire Dawa)
-
-
- • Schedule Fares: Set custom pricing for each schedule by seat class and passenger type
-
-
- • Passenger Type: ADULT (5+ years) or CHILD (<5) — first child travels free, subsequent children pay full fare
-
-
- • Nationality-based: Override fares for specific nationalities (Ethiopian, Djiboutian, Other)
-
-
-
-
{/* Delete Confirmation */}
- {/* Baggage Allowance Modal */}
+ {/* Luggage Allowance Modal */}
{ setBaggageModal(false); setEditingAllowance(null); setBaggageError(null); }} title={editingAllowance ? 'Edit Allowance Rule' : 'Add Allowance Rule'} size="md">
{baggageError &&
{baggageError}
}
diff --git a/apps/edr-passenger-web/backoffice/src/app/seats/page.tsx b/apps/edr-passenger-web/backoffice/src/app/seats/page.tsx
index a5ed35666..413383b7a 100644
--- a/apps/edr-passenger-web/backoffice/src/app/seats/page.tsx
+++ b/apps/edr-passenger-web/backoffice/src/app/seats/page.tsx
@@ -52,15 +52,28 @@ export default function SeatsPage() {
queryFn: async () => {
if (!selectedRoute) return null;
const template: any[] = await routeCoachTemplatesApi.get(selectedRoute);
- if (!template?.length) return [];
- const fullCoaches = await Promise.all(
- template.map((entry: any) => fleetApi.getCoach(entry.coachId ?? entry.coach?.id))
- );
- return fullCoaches.map((coach: any, i: number) => ({
+ if (template?.length) {
+ const fullCoaches = await Promise.all(
+ template.map((entry: any) => fleetApi.getCoach(entry.coachId ?? entry.coach?.id))
+ );
+ return fullCoaches.map((coach: any, i: number) => ({
+ ...coach,
+ coachNumber: coach.number,
+ positionNumber: template[i].positionNumber,
+ seatArrangement: coach.arrangement,
+ }));
+ }
+ // No template — fetch coaches from the most recent schedule for this route
+ const schedules: any = await schedulesApi.getAll({ routeId: selectedRoute });
+ const scheduleList: any[] = schedules?.items || schedules?.data || (Array.isArray(schedules) ? schedules : []);
+ if (!scheduleList.length) return [];
+ const latestSchedule = scheduleList[scheduleList.length - 1];
+ const seatMap: any = await seatsApi.getSeatMap(latestSchedule.id);
+ return (seatMap?.coaches || []).map((coach: any, i: number) => ({
...coach,
- coachNumber: coach.number,
- positionNumber: template[i].positionNumber,
- seatArrangement: coach.arrangement,
+ coachNumber: coach.number ?? coach.coachNumber,
+ positionNumber: coach.positionNumber ?? i + 1,
+ seatArrangement: coach.arrangement ?? coach.seatArrangement,
}));
},
enabled: !!selectedRoute,
diff --git a/apps/edr-passenger-web/backoffice/src/app/support/page.tsx b/apps/edr-passenger-web/backoffice/src/app/support/page.tsx
index 8102cc556..246a6f4f9 100644
--- a/apps/edr-passenger-web/backoffice/src/app/support/page.tsx
+++ b/apps/edr-passenger-web/backoffice/src/app/support/page.tsx
@@ -42,7 +42,7 @@ export default function SupportPage() {
const { data, isLoading } = useConversations(
status === 'ALL' ? { search } : { status, search },
);
- const items = data?.items ?? [];
+ const items = useMemo(() => data?.items ?? [], [data?.items]);
useSupportSocket(true);
diff --git a/apps/edr-passenger-web/backoffice/src/app/tariff-rates/page.tsx b/apps/edr-passenger-web/backoffice/src/app/tariff-rates/page.tsx
index bc8f31eaf..653ffddc3 100644
--- a/apps/edr-passenger-web/backoffice/src/app/tariff-rates/page.tsx
+++ b/apps/edr-passenger-web/backoffice/src/app/tariff-rates/page.tsx
@@ -10,7 +10,6 @@ import Modal from '@/components/ui/Modal';
import ConfirmDialog from '@/components/ui/ConfirmDialog';
import { apiClient } from '@/lib/api-client';
-const NATIONALITY_TYPES = ['LOCAL', 'INTERNATIONAL'] as const;
const BED_POSITIONS = ['UPPER', 'MIDDLE', 'LOWER'] as const;
const COACH_TYPE_LABELS: Record
= {
HSC: 'Regular Seat (Hard Seat)',
@@ -18,7 +17,6 @@ const COACH_TYPE_LABELS: Record = {
SBC: 'VIP Bed (Soft Berth)',
};
-// Tariff reference rates per the official policy document
const TARIFF_REFERENCE: Record> = {
LOCAL: {
'HSC-null': 0.03,
@@ -109,7 +107,7 @@ export default function TariffRatesPage() {
name: fd.get('name') as string,
nationalityType: selectedNationalityType,
bedPosition: selectedBedPosition || null,
- baseFareMinor: parseInt(fd.get('baseFareMinor') as string),
+ basePrice: Math.round(Number(fd.get('baseFareMinor') as string) * 100) || 0,
isActive: fd.get('isActive') === 'true',
};
if (editingClass) {
@@ -127,7 +125,6 @@ export default function TariffRatesPage() {
? classesData
: (classesData as any)?.items || (classesData as any)?.data || [];
- // Only show classes that have nationalityType set (tariff-managed rows)
const tariffClasses = allClasses.filter((c: any) => c.nationalityType);
const displayed = tariffClasses.filter((c: any) => {
@@ -141,7 +138,6 @@ export default function TariffRatesPage() {
);
});
- // Auto-suggest name from selections
const suggestName = () => {
const ct = coachTypesArray.find((c: any) => c.id === selectedCoachTypeId);
if (!ct) return '';
@@ -151,13 +147,12 @@ export default function TariffRatesPage() {
return `${label}${pos} (${nat})`;
};
- // Auto-suggest baseFareMinor from tariff reference
+ // Returns the human-readable rate (e.g. 0.03); stored value = this × 100
const suggestRate = () => {
const ct = coachTypesArray.find((c: any) => c.id === selectedCoachTypeId);
if (!ct) return '';
const ref = getTariffRef(selectedNationalityType, ct.code, selectedBedPosition || null);
- // baseFareMinor = tariff_decimal × 100000
- return ref ? Math.round(ref * 100000).toString() : '';
+ return ref ? String(ref) : '';
};
const columns = [
@@ -184,18 +179,18 @@ export default function TariffRatesPage() {
render: (c: any) => {c.name} ,
},
{
- key: 'baseFareMinor', label: 'Rate per km (minor)',
+ key: 'baseFareMinor', label: 'Rate per km',
render: (c: any) => {
const ct = coachTypesArray.find((t: any) => t.id === c.coachTypeId);
const ref = ct ? getTariffRef(c.nationalityType, ct.code, c.bedPosition) : undefined;
- const tariffMinor = ref ? Math.round(ref * 100000) : undefined;
+ const tariffMinor = ref ? Math.round(ref * 100) : undefined;
const matches = tariffMinor === c.baseFareMinor;
return (
- {c.baseFareMinor}
+ {c.baseFareMinor / 100}
{tariffMinor !== undefined && (
- {matches ? '✓ tariff' : `tariff: ${tariffMinor}`}
+ {matches ? '✓ tariff' : `tariff: ${tariffMinor / 100}`}
)}
@@ -237,17 +232,6 @@ export default function TariffRatesPage() {
- {/* Tariff reference card */}
-
-
Official Tariff Formula
-
- Fare = KM × rate × 1.02 × ExchangeRate
-
-
- Rate is stored as baseFareMinor = tariff_decimal × 100,000 (e.g. 0.03 → 3000). The ×1.02 insurance coefficient is applied automatically by the fare engine.
-
-
-
@@ -377,15 +361,16 @@ export default function TariffRatesPage() {
- Base Fare Minor (per km) *
+ Rate per km *
{suggestRate() && (
@@ -401,7 +386,7 @@ export default function TariffRatesPage() {
>
{suggestRate()}
- {' '}(= {(parseInt(suggestRate()) / 100000).toFixed(3)} ETB/km)
+ {' '}(stored as {Math.round(Number(suggestRate()) * 100)})
)}
diff --git a/apps/edr-passenger-web/backoffice/src/app/tickets/page.tsx b/apps/edr-passenger-web/backoffice/src/app/tickets/page.tsx
index 53ae0584a..275291415 100644
--- a/apps/edr-passenger-web/backoffice/src/app/tickets/page.tsx
+++ b/apps/edr-passenger-web/backoffice/src/app/tickets/page.tsx
@@ -884,11 +884,11 @@ export default function TicketsPage() {
})()}
- {/* Excess Baggage Modal */}
+ {/* Excess Luggage Modal */}
{ setExcessModalOpen(false); setExcessTicket(null); setExcessResult(null); }}
- title="Log Excess Baggage"
+ title="Log Excess Luggage"
size="sm"
>
{excessResult ? (
diff --git a/apps/edr-passenger-web/backoffice/src/features/support/useSupportSocket.ts b/apps/edr-passenger-web/backoffice/src/features/support/useSupportSocket.ts
index c4014f8d2..aabeb0ce4 100644
--- a/apps/edr-passenger-web/backoffice/src/features/support/useSupportSocket.ts
+++ b/apps/edr-passenger-web/backoffice/src/features/support/useSupportSocket.ts
@@ -37,11 +37,27 @@ export function useSupportSocket(
`${SOCKET_ORIGIN}/${Passenger.PASSENGER_SUPPORT_WS_NAMESPACE}`,
{
auth: { token },
- transports: ['websocket'],
+ // Prefer WebSocket, fall back to HTTP long-polling if the proxy blocks
+ // the upgrade (polling rides normal HTTPS, already CSP-allowed).
+ transports: ['websocket', 'polling'],
withCredentials: true,
},
);
+ // Temporary diagnostics — remove once live delivery is confirmed.
+ socket.on('connect', () =>
+ console.warn('[support] agent socket connected', socket.id),
+ );
+ socket.on('connect_error', (err) =>
+ console.warn('[support] agent socket connect_error:', err.message),
+ );
+ socket.on('disconnect', (reason) =>
+ console.warn('[support] agent socket disconnected:', reason),
+ );
+ socket.on('support:hello', (info) =>
+ console.warn('[support] server assigned:', info),
+ );
+
socket.on(
Passenger.PASSENGER_SUPPORT_WS_EVENTS.MESSAGE_NEW,
(event: Passenger.PassengerSupportMessageEvent) => {
diff --git a/apps/edr-passenger-web/backoffice/src/lib/api/index.ts b/apps/edr-passenger-web/backoffice/src/lib/api/index.ts
index 9f3764227..7824cef07 100644
--- a/apps/edr-passenger-web/backoffice/src/lib/api/index.ts
+++ b/apps/edr-passenger-web/backoffice/src/lib/api/index.ts
@@ -444,7 +444,7 @@ export const packageInquiriesApi = {
remove: (id: string) => apiClient.delete(`/packages/inquiries/${id}`),
};
-// Excess Baggage API
+// Excess Luggage API
export const excessBaggageApi = {
logCharge: (data: any) => apiClient.post('/agents/excess-baggage', data),
getCharge: (id: string) => apiClient.get(`/agents/excess-baggage/${id}`),
diff --git a/apps/edr-passenger-web/backoffice/src/middleware.ts b/apps/edr-passenger-web/backoffice/src/middleware.ts
index db7af8568..5ad4e3407 100644
--- a/apps/edr-passenger-web/backoffice/src/middleware.ts
+++ b/apps/edr-passenger-web/backoffice/src/middleware.ts
@@ -35,8 +35,12 @@ function buildCsp(nonce: string): string {
? `'self' 'nonce-${nonce}' 'strict-dynamic'`
: `'self' 'unsafe-inline' 'unsafe-eval'`;
+ // The Socket.IO WebSocket upgrade connects to wss://; under CSP a
+ // `https://host` source does NOT cover `wss://host`, so add it explicitly.
+ const wsOrigin = apiOrigin.replace(/^http/, 'ws'); // https→wss, http→ws
+
const connectSrc = isProd
- ? `'self' ${apiOrigin}`.trim()
+ ? `'self' ${apiOrigin} ${wsOrigin}`.trim()
: `'self' ${apiOrigin} ws: wss:`.trim();
const directives = [
diff --git a/apps/edr-passenger-web/portal/src/app/booking/confirmation/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/confirmation/page.tsx
index 220ecb1b3..515eb4449 100644
--- a/apps/edr-passenger-web/portal/src/app/booking/confirmation/page.tsx
+++ b/apps/edr-passenger-web/portal/src/app/booking/confirmation/page.tsx
@@ -8,7 +8,7 @@ import { usePaymentStore } from '@/lib/payment-store';
import { useQuery } from '@tanstack/react-query';
import { apiClient } from '@/lib/api-client';
import { useEffect, useState, useRef } from 'react';
-import { CheckCircle, Copy, Train, FileText } from 'lucide-react';
+import { CheckCircle, Clock, Copy, Train, FileText } from 'lucide-react';
import { format } from 'date-fns';
import { isChild, isFirstChild } from '@/utils/fare-utils';
@@ -45,7 +45,7 @@ export default function ConfirmationPage() {
return {
id: bookingId || '',
pnr: pnr || undefined,
- status: 'CONFIRMED',
+ status: 'PENDING_PAYMENT',
totalMinor: passengers.reduce((sum) => sum + (selectedSchedule?.baseFareAdult || 0), 0),
};
}
@@ -53,6 +53,10 @@ export default function ConfirmationPage() {
enabled: !!bookingId,
});
+ // Only trust an actually-confirmed booking to show ticket numbers / a "CONFIRMED" badge —
+ // a gateway redirect back here does not mean payment succeeded (see payment return pages).
+ const isConfirmed = _booking?.status === 'CONFIRMED';
+
useEffect(() => {
if (bookingId && !confirmAttempted.current) {
confirmAttempted.current = true;
@@ -190,14 +194,33 @@ export default function ConfirmationPage() {
{/* Success Header */}
-
-
-
+ {isConfirmed ? (
+
+
+
+ ) : (
+
+
+
+ )}
-
- {packageName ? `${packageName} booking confirmed!` : 'Booking confirmed!'}
-
-
Your train tickets are ready
+ {isConfirmed ? (
+ <>
+
+ {packageName ? `${packageName} booking confirmed!` : 'Booking confirmed!'}
+
+
Your train tickets are ready
+ >
+ ) : (
+ <>
+
+ Booking received — payment pending
+
+
+ We haven't confirmed your payment yet. Your tickets will be issued once payment is completed.
+
+ >
+ )}
{/* PNR Card */}
@@ -340,7 +363,9 @@ export default function ConfirmationPage() {
Status
-
{_booking?.status || 'CONFIRMED'}
+
+ {_booking?.status || 'PENDING_PAYMENT'}
+
Passengers
@@ -366,7 +391,9 @@ export default function ConfirmationPage() {
{passengers.map((passenger, index) => {
const backendTicket = _booking?.ticket || null;
- const ticketNumber = backendTicket?.barcodePayload || `TKT-${bookingId?.slice(0, 8).toUpperCase()}-${(index + 1).toString().padStart(2, '0')}`;
+ const ticketNumber = isConfirmed
+ ? backendTicket?.barcodePayload || `TKT-${bookingId?.slice(0, 8).toUpperCase()}-${(index + 1).toString().padStart(2, '0')}`
+ : null;
return (
@@ -377,13 +404,17 @@ export default function ConfirmationPage() {
{passenger.name}
Passenger {index + 1}
-
CONFIRMED
+ {isConfirmed ? (
+
CONFIRMED
+ ) : (
+
AWAITING PAYMENT
+ )}
-
+
Ticket Number
-
{ticketNumber}
+
{ticketNumber || 'Pending payment'}
Date of Birth
diff --git a/apps/edr-passenger-web/portal/src/app/booking/payment/dmoney/failure/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/payment/dmoney/failure/page.tsx
new file mode 100644
index 000000000..bb66105f3
--- /dev/null
+++ b/apps/edr-passenger-web/portal/src/app/booking/payment/dmoney/failure/page.tsx
@@ -0,0 +1,60 @@
+'use client';
+
+import { useSearchParams, useRouter } from 'next/navigation';
+import { usePaymentStore } from '@/lib/payment-store';
+import { consumeManageBookingPaymentReturn } from '@/utils/manage-booking-return';
+import { useEffect, useState, Suspense } from 'react';
+import { XCircle, Loader2, ChevronLeft } from 'lucide-react';
+
+function DmoneyFailureContent() {
+ const router = useRouter();
+ const searchParams = useSearchParams();
+ const { updateStatus } = usePaymentStore();
+ // A Manage Booking payment (paying for an already-existing booking) has no in-progress
+ // booking-store session to go "back to review" from — send it back to that booking's
+ // detail view instead, where the user can pick a different payment method.
+ const [backTarget, setBackTarget] = useState('/booking/review');
+
+ // D-Money callback query params (mirrors Telebirr)
+ const merchantOrderId = searchParams.get('merchantOrderId') || '';
+ const trxRef = searchParams.get('trxRef') || searchParams.get('outTradeNo') || '';
+ const resultCode = searchParams.get('resultCode') || searchParams.get('code') || '';
+ const resultMsg = searchParams.get('resultMsg') || searchParams.get('message') || 'Payment was not completed.';
+
+ useEffect(() => {
+ const manageBookingRef = consumeManageBookingPaymentReturn();
+ if (manageBookingRef) {
+ setBackTarget(`/booking/detail?ref=${encodeURIComponent(manageBookingRef)}`);
+ }
+ updateStatus('FAILED');
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ return (
+
+
+
+
Payment Failed
+
{resultMsg}
+ {resultCode &&
Code: {resultCode}
}
+ {merchantOrderId &&
Order ID: {merchantOrderId}
}
+ {trxRef &&
Ref: {trxRef}
}
+
+ router.push(backTarget)}
+ className="btn-secondary w-full flex items-center justify-center gap-2">
+
+ Back
+
+
+
+
+ );
+}
+
+export default function DmoneyFailurePage() {
+ return (
+
}>
+
+
+ );
+}
diff --git a/apps/edr-passenger-web/portal/src/app/booking/payment/dmoney/success/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/payment/dmoney/success/page.tsx
index 33d6a20f6..cf132bc7f 100644
--- a/apps/edr-passenger-web/portal/src/app/booking/payment/dmoney/success/page.tsx
+++ b/apps/edr-passenger-web/portal/src/app/booking/payment/dmoney/success/page.tsx
@@ -3,15 +3,42 @@
import { useEffect, useState } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { usePaymentStore } from '@/lib/payment-store';
+import { useBookingStore } from '@/lib/booking-store';
import { consumeManageBookingPaymentReturn } from '@/utils/manage-booking-return';
-import { CheckCircle, Loader2 } from 'lucide-react';
+import { apiClient } from '@/lib/api-client';
+import { CheckCircle, XCircle, Loader2, ChevronLeft } from 'lucide-react';
import { Suspense } from 'react';
+type IntentStatus = 'PENDING' | 'PROCESSING' | 'REQUIRES_ACTION' | 'SUCCEEDED' | 'FAILED';
+type ViewState = 'checking' | 'succeeded' | 'failed' | 'unknown';
+
+// D-Money redirects the browser to this ONE url regardless of outcome — a hit here is not
+// proof of payment. Poll the backend (which reconciles with the provider) before showing
+// "Payment Successful". Real confirmation still happens via the webhook; this only decides
+// what the browser shows.
+async function verifyBookingPaid(bookingId: string): Promise<'SUCCEEDED' | 'FAILED' | 'UNKNOWN'> {
+ const maxAttempts = 5;
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
+ try {
+ const intent = await apiClient.get<{ status: IntentStatus }>(`/payments/intents/${bookingId}`);
+ if (intent?.status === 'SUCCEEDED') return 'SUCCEEDED';
+ if (intent?.status === 'FAILED') return 'FAILED';
+ } catch {
+ // transient lookup failure — keep retrying until attempts are exhausted
+ }
+ if (attempt < maxAttempts - 1) {
+ await new Promise((resolve) => setTimeout(resolve, 1500));
+ }
+ }
+ return 'UNKNOWN';
+}
+
function DmoneySuccessContent() {
const router = useRouter();
const searchParams = useSearchParams();
const { updateStatus } = usePaymentStore();
- const [status, setStatus] = useState<'processing' | 'done' | 'error'>('processing');
+ const { bookingId } = useBookingStore();
+ const [view, setView] = useState
('checking');
const [returnTarget, setReturnTarget] = useState('/booking/confirmation');
// D-Money callback query params (mirrors Telebirr)
@@ -19,30 +46,56 @@ function DmoneySuccessContent() {
const trxRef = searchParams.get('trxRef') || searchParams.get('outTradeNo') || '';
useEffect(() => {
- // Actual booking confirmation happens server-side via the provider webhook — this page
- // only reflects that back to the user. A Manage Booking payment (paying for an
- // already-existing booking) has no in-progress booking-store session to show a
- // confirmation from, so it goes back to that booking's detail view instead.
+ let cancelled = false;
+
const manageBookingRef = consumeManageBookingPaymentReturn();
const target = manageBookingRef ? `/booking/detail?ref=${encodeURIComponent(manageBookingRef)}` : '/booking/confirmation';
setReturnTarget(target);
- updateStatus('SUCCEEDED');
- setStatus('done');
- setTimeout(() => router.push(target), 1500);
+
+ // Manage Booking sessions don't carry a bookingId in the client store — the detail page
+ // it lands on re-fetches the booking's real status itself, so there's nothing to verify
+ // client-side here; just hand off without claiming an outcome we can't confirm.
+ if (!bookingId) {
+ if (!cancelled) {
+ router.push(target);
+ }
+ return;
+ }
+
+ verifyBookingPaid(bookingId).then((result) => {
+ if (cancelled) return;
+ if (result === 'SUCCEEDED') {
+ updateStatus('SUCCEEDED');
+ setView('succeeded');
+ setTimeout(() => router.push(target), 1500);
+ } else if (result === 'FAILED') {
+ updateStatus('FAILED');
+ setView('failed');
+ } else {
+ // Still not confirmed after polling — don't claim success or failure. Hand off to
+ // /booking/confirmation, which now reflects the booking's real (pending) status.
+ setView('unknown');
+ setTimeout(() => router.push(target), 1500);
+ }
+ });
+
+ return () => {
+ cancelled = true;
+ };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
- {status === 'processing' && (
+ {view === 'checking' && (
<>
Confirming payment…
Please wait while we confirm your D-Money payment.
>
)}
- {status === 'done' && (
+ {view === 'succeeded' && (
<>
Payment Successful!
@@ -52,15 +105,25 @@ function DmoneySuccessContent() {
Redirecting…
>
)}
- {status === 'error' && (
+ {view === 'failed' && (
<>
-
- ⚠️
-
-
Something went wrong
-
Unable to confirm payment
+
+
Payment Failed
+
Your D-Money payment was not completed.
router.push(returnTarget)}
- className="btn-primary w-full">Continue
+ className="btn-secondary w-full flex items-center justify-center gap-2">
+
+ Back
+
+ >
+ )}
+ {view === 'unknown' && (
+ <>
+
+
Still confirming…
+
+ We haven't received final confirmation from D-Money yet. Taking you to your booking status.
+
>
)}
diff --git a/apps/edr-passenger-web/portal/src/app/booking/payment/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/payment/page.tsx
index a95179762..ceb54acd9 100644
--- a/apps/edr-passenger-web/portal/src/app/booking/payment/page.tsx
+++ b/apps/edr-passenger-web/portal/src/app/booking/payment/page.tsx
@@ -436,7 +436,7 @@ export default function PaymentPage() {
) : (
- {paymentMethods.map((method) => {
+ {paymentMethods.filter(m => m.enabled).map((method) => {
const Icon = getIconForMethod(method.type);
const isSelected = selectedMethod === method.type;
return (
diff --git a/apps/edr-passenger-web/portal/src/app/booking/payment/telebirr/success/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/payment/telebirr/success/page.tsx
index 1a830cee3..a47fdd07c 100644
--- a/apps/edr-passenger-web/portal/src/app/booking/payment/telebirr/success/page.tsx
+++ b/apps/edr-passenger-web/portal/src/app/booking/payment/telebirr/success/page.tsx
@@ -3,15 +3,42 @@
import { useEffect, useState } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { usePaymentStore } from '@/lib/payment-store';
+import { useBookingStore } from '@/lib/booking-store';
import { consumeManageBookingPaymentReturn } from '@/utils/manage-booking-return';
-import { CheckCircle, Loader2 } from 'lucide-react';
+import { apiClient } from '@/lib/api-client';
+import { CheckCircle, XCircle, Loader2, ChevronLeft } from 'lucide-react';
import { Suspense } from 'react';
+type IntentStatus = 'PENDING' | 'PROCESSING' | 'REQUIRES_ACTION' | 'SUCCEEDED' | 'FAILED';
+type ViewState = 'checking' | 'succeeded' | 'failed' | 'unknown';
+
+// Telebirr redirects the browser to this ONE url regardless of outcome — a hit here is not
+// proof of payment. Poll the backend (which reconciles with the provider) before showing
+// "Payment Successful". Real confirmation still happens via the webhook; this only decides
+// what the browser shows.
+async function verifyBookingPaid(bookingId: string): Promise<'SUCCEEDED' | 'FAILED' | 'UNKNOWN'> {
+ const maxAttempts = 5;
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
+ try {
+ const intent = await apiClient.get<{ status: IntentStatus }>(`/payments/intents/${bookingId}`);
+ if (intent?.status === 'SUCCEEDED') return 'SUCCEEDED';
+ if (intent?.status === 'FAILED') return 'FAILED';
+ } catch {
+ // transient lookup failure — keep retrying until attempts are exhausted
+ }
+ if (attempt < maxAttempts - 1) {
+ await new Promise((resolve) => setTimeout(resolve, 1500));
+ }
+ }
+ return 'UNKNOWN';
+}
+
function TelebirrSuccessContent() {
const router = useRouter();
const searchParams = useSearchParams();
const { updateStatus } = usePaymentStore();
- const [status, setStatus] = useState<'processing' | 'done' | 'error'>('processing');
+ const { bookingId } = useBookingStore();
+ const [view, setView] = useState
('checking');
const [returnTarget, setReturnTarget] = useState('/booking/confirmation');
// Telebirr callback query params
@@ -19,30 +46,56 @@ function TelebirrSuccessContent() {
const trxRef = searchParams.get('trxRef') || searchParams.get('outTradeNo') || '';
useEffect(() => {
- // Actual booking confirmation happens server-side via the provider webhook — this page
- // only reflects that back to the user. A Manage Booking payment (paying for an
- // already-existing booking) has no in-progress booking-store session to show a
- // confirmation from, so it goes back to that booking's detail view instead.
+ let cancelled = false;
+
const manageBookingRef = consumeManageBookingPaymentReturn();
const target = manageBookingRef ? `/booking/detail?ref=${encodeURIComponent(manageBookingRef)}` : '/booking/confirmation';
setReturnTarget(target);
- updateStatus('SUCCEEDED');
- setStatus('done');
- setTimeout(() => router.push(target), 1500);
+
+ // Manage Booking sessions don't carry a bookingId in the client store — the detail page
+ // it lands on re-fetches the booking's real status itself, so there's nothing to verify
+ // client-side here; just hand off without claiming an outcome we can't confirm.
+ if (!bookingId) {
+ if (!cancelled) {
+ router.push(target);
+ }
+ return;
+ }
+
+ verifyBookingPaid(bookingId).then((result) => {
+ if (cancelled) return;
+ if (result === 'SUCCEEDED') {
+ updateStatus('SUCCEEDED');
+ setView('succeeded');
+ setTimeout(() => router.push(target), 1500);
+ } else if (result === 'FAILED') {
+ updateStatus('FAILED');
+ setView('failed');
+ } else {
+ // Still not confirmed after polling — don't claim success or failure. Hand off to
+ // /booking/confirmation, which now reflects the booking's real (pending) status.
+ setView('unknown');
+ setTimeout(() => router.push(target), 1500);
+ }
+ });
+
+ return () => {
+ cancelled = true;
+ };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
- {status === 'processing' && (
+ {view === 'checking' && (
<>
Confirming payment…
Please wait while we confirm your Telebirr payment.
>
)}
- {status === 'done' && (
+ {view === 'succeeded' && (
<>
Payment Successful!
@@ -52,15 +105,25 @@ function TelebirrSuccessContent() {
Redirecting…
>
)}
- {status === 'error' && (
+ {view === 'failed' && (
<>
-
- ⚠️
-
-
Something went wrong
-
Unable to confirm payment
+
+
Payment Failed
+
Your Telebirr payment was not completed.
router.push(returnTarget)}
- className="btn-primary w-full">Continue
+ className="btn-secondary w-full flex items-center justify-center gap-2">
+
+ Back
+
+ >
+ )}
+ {view === 'unknown' && (
+ <>
+
+
Still confirming…
+
+ We haven't received final confirmation from Telebirr yet. Taking you to your booking status.
+
>
)}
diff --git a/apps/edr-passenger-web/portal/src/app/booking/payment/waafi/success/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/payment/waafi/success/page.tsx
index c75a0c390..89a8b3098 100644
--- a/apps/edr-passenger-web/portal/src/app/booking/payment/waafi/success/page.tsx
+++ b/apps/edr-passenger-web/portal/src/app/booking/payment/waafi/success/page.tsx
@@ -3,14 +3,41 @@
import { useEffect, useState, Suspense } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { usePaymentStore } from '@/lib/payment-store';
+import { useBookingStore } from '@/lib/booking-store';
import { consumeManageBookingPaymentReturn } from '@/utils/manage-booking-return';
-import { CheckCircle, Loader2 } from 'lucide-react';
+import { apiClient } from '@/lib/api-client';
+import { CheckCircle, XCircle, Loader2, ChevronLeft } from 'lucide-react';
+
+type IntentStatus = 'PENDING' | 'PROCESSING' | 'REQUIRES_ACTION' | 'SUCCEEDED' | 'FAILED';
+type ViewState = 'checking' | 'succeeded' | 'failed' | 'unknown';
+
+// Waafi registers a dedicated success URL, but a hit here still isn't proof of payment on
+// its own (gateway redirect vs. real settlement can disagree). Poll the backend (which
+// reconciles with the provider) before showing "Payment Successful".
+async function verifyBookingPaid(bookingId: string): Promise<'SUCCEEDED' | 'FAILED' | 'UNKNOWN'> {
+ const maxAttempts = 5;
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
+ try {
+ const intent = await apiClient.get<{ status: IntentStatus }>(`/payments/intents/${bookingId}`);
+ if (intent?.status === 'SUCCEEDED') return 'SUCCEEDED';
+ if (intent?.status === 'FAILED') return 'FAILED';
+ } catch {
+ // transient lookup failure — keep retrying until attempts are exhausted
+ }
+ if (attempt < maxAttempts - 1) {
+ await new Promise((resolve) => setTimeout(resolve, 1500));
+ }
+ }
+ return 'UNKNOWN';
+}
function WaafiSuccessContent() {
const router = useRouter();
const searchParams = useSearchParams();
const { updateStatus } = usePaymentStore();
- const [status, setStatus] = useState<'processing' | 'done' | 'error'>('processing');
+ const { bookingId } = useBookingStore();
+ const [view, setView] = useState
('checking');
+ const [returnTarget, setReturnTarget] = useState('/booking/confirmation');
// Waafi callback query params
const referenceId = searchParams.get('referenceId') || '';
@@ -19,29 +46,56 @@ function WaafiSuccessContent() {
const currency = searchParams.get('currency') || '';
useEffect(() => {
- // Actual booking confirmation happens server-side via the provider webhook — this page
- // only reflects that back to the user. A Manage Booking payment (paying for an
- // already-existing booking) has no in-progress booking-store session to show a
- // confirmation from, so it goes back to that booking's detail view instead.
+ let cancelled = false;
+
const manageBookingRef = consumeManageBookingPaymentReturn();
const target = manageBookingRef ? `/booking/detail?ref=${encodeURIComponent(manageBookingRef)}` : '/booking/confirmation';
- updateStatus('SUCCEEDED');
- setStatus('done');
- setTimeout(() => router.push(target), 1500);
+ setReturnTarget(target);
+
+ // Manage Booking sessions don't carry a bookingId in the client store — the detail page
+ // it lands on re-fetches the booking's real status itself, so there's nothing to verify
+ // client-side here; just hand off without claiming an outcome we can't confirm.
+ if (!bookingId) {
+ if (!cancelled) {
+ router.push(target);
+ }
+ return;
+ }
+
+ verifyBookingPaid(bookingId).then((result) => {
+ if (cancelled) return;
+ if (result === 'SUCCEEDED') {
+ updateStatus('SUCCEEDED');
+ setView('succeeded');
+ setTimeout(() => router.push(target), 1500);
+ } else if (result === 'FAILED') {
+ updateStatus('FAILED');
+ setView('failed');
+ } else {
+ // Still not confirmed after polling — don't claim success or failure. Hand off to
+ // /booking/confirmation, which now reflects the booking's real (pending) status.
+ setView('unknown');
+ setTimeout(() => router.push(target), 1500);
+ }
+ });
+
+ return () => {
+ cancelled = true;
+ };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
- {status === 'processing' && (
+ {view === 'checking' && (
<>
Confirming payment…
Please wait while we confirm your Waafi payment.
>
)}
- {status === 'done' && (
+ {view === 'succeeded' && (
<>
Payment Successful!
@@ -54,6 +108,27 @@ function WaafiSuccessContent() {
Redirecting…
>
)}
+ {view === 'failed' && (
+ <>
+
+
Payment Failed
+
Your Waafi payment was not completed.
+
router.push(returnTarget)}
+ className="btn-secondary w-full flex items-center justify-center gap-2">
+
+ Back
+
+ >
+ )}
+ {view === 'unknown' && (
+ <>
+
+
Still confirming…
+
+ We haven't received final confirmation from Waafi yet. Taking you to your booking status.
+
+ >
+ )}
);
diff --git a/apps/edr-passenger-web/portal/src/app/booking/results/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/results/page.tsx
index 77a18380f..0f8ac2004 100644
--- a/apps/edr-passenger-web/portal/src/app/booking/results/page.tsx
+++ b/apps/edr-passenger-web/portal/src/app/booking/results/page.tsx
@@ -1,57 +1,106 @@
-'use client';
+"use client";
-import { useSearchParams, useRouter } from 'next/navigation';
-import { useQuery } from '@tanstack/react-query';
-import { apiClient } from '@/lib/api-client';
-import { useBookingStore } from '@/lib/booking-store';
-import { Schedule } from '@/types';
-import { ArrowRight, Clock, Calendar, Users, ChevronLeft, Check, X, MapPin, Gift, Train, Bed, Armchair, Star } from 'lucide-react';
-import { format } from 'date-fns';
-import { formatTime, getTimePeriod } from '@/utils/format';
-import { useState, useEffect } from 'react';
+import { useSearchParams, useRouter } from "next/navigation";
+import { useQuery } from "@tanstack/react-query";
+import { apiClient } from "@/lib/api-client";
+import { useBookingStore } from "@/lib/booking-store";
+import { Schedule } from "@/types";
+import {
+ ArrowRight,
+ Clock,
+ Calendar,
+ Users,
+ ChevronLeft,
+ Check,
+ X,
+ MapPin,
+ Gift,
+ Train,
+ Bed,
+ Armchair,
+ Star,
+} from "lucide-react";
+import { format } from "date-fns";
+import { formatTime, getTimePeriod } from "@/utils/format";
+import { useState, useEffect } from "react";
export default function ResultsPage() {
const router = useRouter();
const searchParams = useSearchParams();
- const { setSelectedSchedule, setOutboundSchedule, setInboundSchedule } = useBookingStore();
- const [selectedCoachTypes, setSelectedCoachTypes] = useState>({});
+ const { setSelectedSchedule, setOutboundSchedule, setInboundSchedule } =
+ useBookingStore();
+ const [selectedCoachTypes, setSelectedCoachTypes] = useState<
+ Record
+ >({});
const [outboundScheduleData, setOutboundScheduleData] = useState(
() => useBookingStore.getState().outboundSchedule,
);
const [classModal, setClassModal] = useState(null);
- const [promoData, setPromoData] = useState<{ code: string; discount: string; message: string } | null>(null);
- const [roundTripStep, setRoundTripStep] = useState<'outbound' | 'inbound'>(() => {
- const { outboundSchedule, searchCriteria: sc } = useBookingStore.getState();
- return outboundSchedule && sc?.tripType === 'ROUND_TRIP' ? 'inbound' : 'outbound';
- });
+ const [promoData, setPromoData] = useState<{
+ code: string;
+ discount: string;
+ message: string;
+ } | null>(null);
+ const [roundTripStep, setRoundTripStep] = useState<"outbound" | "inbound">(
+ () => {
+ const { outboundSchedule, searchCriteria: sc } =
+ useBookingStore.getState();
+ return outboundSchedule && sc?.tripType === "ROUND_TRIP"
+ ? "inbound"
+ : "outbound";
+ },
+ );
const searchCriteria = useBookingStore((s) => s.searchCriteria);
const setSearchCriteria = useBookingStore((s) => s.setSearchCriteria);
const searchData = {
- originStationId: searchParams.get('origin') || searchCriteria?.originStationId || '',
- destinationStationId: searchParams.get('destination') || searchCriteria?.destinationStationId || '',
- date: searchParams.get('date') || searchCriteria?.departureDate || '',
- returnDate: searchParams.get('returnDate') || searchCriteria?.returnDate,
- journeyType: (searchParams.get('tripType') ?? searchCriteria?.tripType ?? 'ONE_WAY') === 'ROUND_TRIP' ? 'ROUND_TRIP' : 'ONE_WAY',
- adultCount: parseInt(searchParams.get('adults') || '') || searchCriteria?.adultCount || 1,
- childCount: parseInt(searchParams.get('children') || '') || searchCriteria?.childCount || 0,
- nationality: searchParams.get('nationality') || searchCriteria?.nationality || 'ETHIOPIAN',
- promoCode: searchParams.get('promoCode') || searchCriteria?.promoCode || '',
+ originStationId:
+ searchParams.get("origin") || searchCriteria?.originStationId || "",
+ destinationStationId:
+ searchParams.get("destination") ||
+ searchCriteria?.destinationStationId ||
+ "",
+ date: searchParams.get("date") || searchCriteria?.departureDate || "",
+ returnDate: searchParams.get("returnDate") || searchCriteria?.returnDate,
+ journeyType:
+ (searchParams.get("tripType") ??
+ searchCriteria?.tripType ??
+ "ONE_WAY") === "ROUND_TRIP"
+ ? "ROUND_TRIP"
+ : "ONE_WAY",
+ adultCount:
+ parseInt(searchParams.get("adults") || "") ||
+ searchCriteria?.adultCount ||
+ 1,
+ childCount:
+ parseInt(searchParams.get("children") || "") ||
+ searchCriteria?.childCount ||
+ 0,
+ nationality:
+ searchParams.get("nationality") ||
+ searchCriteria?.nationality ||
+ "ETHIOPIAN",
+ promoCode: searchParams.get("promoCode") || searchCriteria?.promoCode || "",
};
useEffect(() => {
- if (searchParams.get('origin')) {
+ if (searchParams.get("origin")) {
setSearchCriteria({
- tripType: (searchParams.get('tripType') || 'ONE_WAY') as 'ONE_WAY' | 'ROUND_TRIP',
- originStationId: searchParams.get('origin')!,
- destinationStationId: searchParams.get('destination')!,
- departureDate: searchParams.get('date')!,
- returnDate: searchParams.get('returnDate') || undefined,
- adultCount: parseInt(searchParams.get('adults') || '1'),
- childCount: parseInt(searchParams.get('children') || '0'),
- nationality: (searchParams.get('nationality') || 'ETHIOPIAN') as 'ETHIOPIAN' | 'DJIBOUTIAN' | 'OTHER',
- promoCode: searchParams.get('promoCode') || '',
+ tripType: (searchParams.get("tripType") || "ONE_WAY") as
+ | "ONE_WAY"
+ | "ROUND_TRIP",
+ originStationId: searchParams.get("origin")!,
+ destinationStationId: searchParams.get("destination")!,
+ departureDate: searchParams.get("date")!,
+ returnDate: searchParams.get("returnDate") || undefined,
+ adultCount: parseInt(searchParams.get("adults") || "1"),
+ childCount: parseInt(searchParams.get("children") || "0"),
+ nationality: (searchParams.get("nationality") || "ETHIOPIAN") as
+ | "ETHIOPIAN"
+ | "DJIBOUTIAN"
+ | "OTHER",
+ promoCode: searchParams.get("promoCode") || "",
});
}
}, [searchParams, setSearchCriteria]);
@@ -59,13 +108,13 @@ export default function ResultsPage() {
useEffect(() => {
if (searchData.promoCode) {
apiClient
- .post('/promos/validate', { code: searchData.promoCode })
+ .post("/promos/validate", { code: searchData.promoCode })
.then((response: any) => {
if (response.applicable || response.valid) {
setPromoData({
code: searchData.promoCode,
- discount: response.message || 'Discount applied',
- message: response.message || 'Promo code applied successfully!',
+ discount: response.message || "Discount applied",
+ message: response.message || "Promo code applied successfully!",
});
}
})
@@ -90,8 +139,12 @@ export default function ResultsPage() {
return `/booking/search?${params}`;
};
- const { data: results, isLoading, error } = useQuery({
- queryKey: ['search', searchData],
+ const {
+ data: results,
+ isLoading,
+ error,
+ } = useQuery({
+ queryKey: ["search", searchData],
queryFn: async (): Promise => {
const payload: any = {
originStationId: searchData.originStationId,
@@ -102,15 +155,13 @@ export default function ResultsPage() {
nationality: searchData.nationality,
journeyType: searchData.journeyType,
};
-
- if (searchData.journeyType === 'ROUND_TRIP' && searchData.returnDate) {
+
+ if (searchData.journeyType === "ROUND_TRIP" && searchData.returnDate) {
payload.returnDate = searchData.returnDate;
}
-
-
- const response = await apiClient.post('/search', payload) as any;
-
-
+
+ const response = (await apiClient.post("/search", payload)) as any;
+
return response;
},
enabled: !!searchData.originStationId && !!searchData.destinationStationId,
@@ -118,20 +169,20 @@ export default function ResultsPage() {
gcTime: 0,
});
- const isRoundTrip = searchData.journeyType === 'ROUND_TRIP';
-
+ const isRoundTrip = searchData.journeyType === "ROUND_TRIP";
+
// Handle both response formats:
// 1. One-way: response can be array of schedules OR object with journeyType and outbound
// 2. Round-trip: response has journeyType, outbound, inbound properties
let outboundSchedules: Schedule[] = [];
let inboundSchedules: Schedule[] = [];
-
+
if (results) {
- if (results.journeyType === 'ROUND_TRIP') {
+ if (results.journeyType === "ROUND_TRIP") {
// Round trip response format
outboundSchedules = results.outbound || [];
inboundSchedules = results.inbound || [];
- } else if (results.journeyType === 'ONE_WAY' && results.outbound) {
+ } else if (results.journeyType === "ONE_WAY" && results.outbound) {
// One-way response format with outbound array
outboundSchedules = results.outbound || [];
} else if (Array.isArray(results)) {
@@ -142,40 +193,68 @@ export default function ResultsPage() {
outboundSchedules = results.data;
}
}
-
- // Alternatives are surfaced whenever a leg returns no exact-date results.
- const alternativeOutbound: Schedule[] = (!!results && outboundSchedules.length === 0) ? (results?.alternativeOutbound || []) : [];
- const alternativeInbound: Schedule[] = (isRoundTrip && !!results && inboundSchedules.length === 0) ? (results?.alternativeInbound || []) : [];
- const requestedDate: string = (results && results.requestedDate) || searchData.date;
- const requestedReturnDate: string = (results && results.requestedReturnDate) || searchData.returnDate || '';
- const isOneWayNoOutbound = !isRoundTrip && !!results && outboundSchedules.length === 0;
+ // Alternatives are surfaced whenever a leg returns no exact-date results.
+ const alternativeOutbound: Schedule[] =
+ !!results && outboundSchedules.length === 0
+ ? results?.alternativeOutbound || []
+ : [];
+ const alternativeInbound: Schedule[] =
+ isRoundTrip && !!results && inboundSchedules.length === 0
+ ? results?.alternativeInbound || []
+ : [];
+ const requestedDate: string =
+ (results && results.requestedDate) || searchData.date;
+ const requestedReturnDate: string =
+ (results && results.requestedReturnDate) || searchData.returnDate || "";
+
+ const isOneWayNoOutbound =
+ !isRoundTrip && !!results && outboundSchedules.length === 0;
// Round-trip: show results view if either leg has exact results OR alternatives.
// One-way: need at least one outbound result.
const hasResults = isRoundTrip
- ? (outboundSchedules.length > 0 || alternativeOutbound.length > 0) || (inboundSchedules.length > 0 || alternativeInbound.length > 0)
+ ? outboundSchedules.length > 0 ||
+ alternativeOutbound.length > 0 ||
+ inboundSchedules.length > 0 ||
+ alternativeInbound.length > 0
: outboundSchedules.length > 0;
- const handleSelectCoachType = (scheduleId: string, coachTypeId: string, coachTypeCode: string, coachTypeName: string, seatClassName: string) => {
- setSelectedCoachTypes(prev => ({ ...prev, [scheduleId]: { id: coachTypeId, code: coachTypeCode, name: coachTypeName, seatClassName } }));
+ const handleSelectCoachType = (
+ scheduleId: string,
+ coachTypeId: string,
+ coachTypeCode: string,
+ coachTypeName: string,
+ seatClassName: string,
+ ) => {
+ setSelectedCoachTypes((prev) => ({
+ ...prev,
+ [scheduleId]: {
+ id: coachTypeId,
+ code: coachTypeCode,
+ name: coachTypeName,
+ seatClassName,
+ },
+ }));
};
const handleSelect = (schedule: Schedule, isOutbound: boolean = false) => {
- const scheduleId = schedule.scheduleId || schedule.id || '';
+ const scheduleId = schedule.scheduleId || schedule.id || "";
const selectedCoachType = selectedCoachTypes[scheduleId];
-
+
if (!selectedCoachType) {
- alert('Please select a coach type before continuing');
+ alert("Please select a coach type before continuing");
return;
}
// Find the coach type to get pricing info
- const coachType = schedule.coachTypes?.find(ct => ct.coachTypeCode === selectedCoachType.code);
+ const coachType = schedule.coachTypes?.find(
+ (ct) => ct.coachTypeCode === selectedCoachType.code,
+ );
// Use displayAmountMinor (passenger's currency) so stored fare matches what the card showed.
const minFare = coachType?.classes.length
- ? Math.min(...coachType.classes.map(c => c.baseFareMinor))
+ ? Math.min(...coachType.classes.map((c) => c.baseFareMinor))
: 0;
- const fareCurrency = 'ETB';
+ const fareCurrency = "ETB";
const hours = Math.floor((schedule.durationMinutes || 0) / 60);
const minutes = (schedule.durationMinutes || 0) % 60;
@@ -184,12 +263,13 @@ export default function ResultsPage() {
const scheduleData = {
id: scheduleId,
trainNumber: schedule.trainNumber,
- origin: schedule.origin?.name || 'Origin',
- destination: schedule.destination?.name || 'Destination',
- originStationId: schedule.origin?.id || schedule.originStationId || '',
- destinationStationId: schedule.destination?.id || schedule.destinationStationId || '',
- departureTime: schedule.departureAt || schedule.departureTime || '',
- arrivalTime: schedule.arrivalAt || schedule.arrivalTime || '',
+ origin: schedule.origin?.name || "Origin",
+ destination: schedule.destination?.name || "Destination",
+ originStationId: schedule.origin?.id || schedule.originStationId || "",
+ destinationStationId:
+ schedule.destination?.id || schedule.destinationStationId || "",
+ departureTime: schedule.departureAt || schedule.departureTime || "",
+ arrivalTime: schedule.arrivalAt || schedule.arrivalTime || "",
duration: durationStr,
baseFareAdult: minFare,
baseFareChild: minFare,
@@ -199,7 +279,8 @@ export default function ResultsPage() {
selectedCoachTypeId: selectedCoachType.id,
selectedCoachTypeCode: selectedCoachType.code,
selectedCoachTypeName: selectedCoachType.name,
- seatClassName: (selectedCoachType as any).seatClassName || selectedCoachType.name,
+ seatClassName:
+ (selectedCoachType as any).seatClassName || selectedCoachType.name,
// Retained so the seat map's coach preview can price a switch to a different
// coach type without needing a fresh API call.
coachTypes: schedule.coachTypes || [],
@@ -210,8 +291,8 @@ export default function ResultsPage() {
setOutboundScheduleData(scheduleData);
setOutboundSchedule(scheduleData);
setClassModal(null);
- setRoundTripStep('inbound');
- window.scrollTo({ top: 0, behavior: 'smooth' });
+ setRoundTripStep("inbound");
+ window.scrollTo({ top: 0, behavior: "smooth" });
return;
}
@@ -223,8 +304,8 @@ export default function ResultsPage() {
// For one-way
setSelectedSchedule(scheduleData);
}
-
- router.push('/booking/auth-check');
+
+ router.push("/booking/auth-check");
};
// Shared "Choose Your Coach" drawer — used by both the normal results view and the
@@ -233,121 +314,181 @@ export default function ResultsPage() {
const renderClassModal = () => {
if (!classModal) return null;
- const scheduleId = classModal.scheduleId || classModal.id || '';
+ const scheduleId = classModal.scheduleId || classModal.id || "";
const selectedCoachType = selectedCoachTypes[scheduleId];
const isOutbound = (classModal as any).isOutbound;
// Dining coaches aren't bookable seat/bed classes — exclude them from selection.
- const coachTypes = (classModal.coachTypes || []).filter((ct: any) => ct.coachTypeCode !== 'DPC');
+ const coachTypes = (classModal.coachTypes || []).filter(
+ (ct: any) => ct.coachTypeCode !== "DPC",
+ );
const getCoachIcon = (typeName: string) => {
const lower = typeName.toLowerCase();
- if (lower.includes('soft') || lower.includes('vip')) return Star;
- if (lower.includes('bed')) return Bed;
+ if (lower.includes("soft") || lower.includes("vip")) return Star;
+ if (lower.includes("bed")) return Bed;
return Armchair;
};
return (
<>
- setClassModal(null)} />
-
setClassModal(null)}
+ />
+
-
-
-
Choose Your Coach
-
-
- {classModal.trainNumber}
- ·
- {classModal.origin?.name} → {classModal.destination?.name}
-
-
-
setClassModal(null)}
- className="w-10 h-10 flex items-center justify-center rounded-full hover:bg-gray-100 dark:hover:bg-gray-800 transition-all"
- aria-label="Close"
- >
-
-
+
+
+
+ Choose Your Coach
+
+
+
+ {classModal.trainNumber}
+ ·
+
+ {classModal.origin?.name} → {classModal.destination?.name}
+
+
+
setClassModal(null)}
+ className="w-10 h-10 flex items-center justify-center rounded-full hover:bg-gray-100 dark:hover:bg-gray-800 transition-all"
+ aria-label="Close"
+ >
+
+
+
-
- {coachTypes.length > 0 ? (
-
- {coachTypes.map((coachType: any, index: number) => {
- const isSelected = selectedCoachType?.id === coachType.coachTypeId;
- const minPrice = coachType.classes.length ? Math.min(...coachType.classes.map((c: any) => c.baseFareMinor)) : 0;
- const coachCurrency = 'ETB';
- const CoachIcon = getCoachIcon(coachType.coachTypeName);
+
+ {coachTypes.length > 0 ? (
+
+ {coachTypes.map((coachType: any, index: number) => {
+ const isSelected =
+ selectedCoachType?.id === coachType.coachTypeId;
+ const minPrice = coachType.classes.length
+ ? Math.min(
+ ...coachType.classes.map((c: any) => c.baseFareMinor),
+ )
+ : 0;
+ const coachCurrency = "ETB";
+ const CoachIcon = getCoachIcon(coachType.coachTypeName);
- return (
-
handleSelectCoachType(scheduleId, coachType.coachTypeId, coachType.coachTypeCode, coachType.coachTypeName, coachType.classes?.[0]?.name || coachType.coachTypeName)}
- className={`group relative w-full p-5 rounded-2xl border-2 text-left transition-all duration-200 ${
+ const selectThisCoach = () =>
+ handleSelectCoachType(
+ scheduleId,
+ coachType.coachTypeId,
+ coachType.coachTypeCode,
+ coachType.coachTypeName,
+ coachType.classes?.[0]?.name ||
+ coachType.coachTypeName,
+ );
+
+ return (
+ {
+ if (e.key === "Enter" || e.key === " ") {
+ e.preventDefault();
+ selectThisCoach();
+ }
+ }}
+ className={`group relative w-full p-5 rounded-2xl border-2 text-left transition-all duration-200 cursor-pointer ${
+ isSelected
+ ? "border-primary bg-gradient-to-br from-primary/8 to-primary/3 dark:from-primary/15 dark:to-primary/5 shadow-lg shadow-primary/20 scale-[1.02]"
+ : "border-gray-200 dark:border-gray-700 shadow-sm hover:border-primary/40 hover:shadow-md hover:scale-[1.01] bg-white dark:bg-gray-800/50"
+ }`}
+ style={{
+ animation: `fade-in-up 0.3s ease-out ${index * 0.1}s both`,
+ }}
+ >
+ {/* Radio indicator — top-right, persistent (not hover-only) so the
+ card's selection state is clear on touch too. */}
+
{isSelected && (
-
-
-
+
)}
+
-
-
-
+
+
-
-
-
-
-
-
-
- {coachType.coachTypeName}
-
-
-
-
-
-
- From
-
- {(minPrice / 100).toFixed(2)}
-
- {coachCurrency}
-
-
-
+ ? "bg-primary/15 dark:bg-primary/25 shadow-inner"
+ : "bg-gray-100 dark:bg-gray-700 group-hover:bg-primary/10"
+ }`}
+ >
+
- {coachType.classes.length > 0 && (
-
-
-
- Class Options
-
+
+
+
+
+ {coachType.coachTypeName}
+
+
+
+
+
+
- {coachType.classes.length} available
+ From
+
+
+ {(minPrice / 100).toFixed(2)}
+
+
+ {coachCurrency}
-
- {coachType.classes.map((cls: any, idx: number) => (
+
+
+
+
+ {coachType.classes.length > 0 && (
+
+
+
+ Class Options
+
+
+ {coachType.classes.length} available
+
+
+
+ {coachType.classes.map(
+ (cls: any, idx: number) => (
@@ -364,43 +505,56 @@ export default function ResultsPage() {
- ))}
-
+ ),
+ )}
- )}
-
-
- );
- })}
-
- ) : (
-
-
-
-
-
No coach types available for this journey
-
- )}
-
+
+ )}
-
-
-
{ if (selectedCoachType) { handleSelect(classModal, isOutbound); } }}
- disabled={!selectedCoachType}
- className="w-full flex items-center justify-center gap-2.5 px-6 py-3.5 bg-gradient-to-r from-[rgb(20,113,76)] to-[rgb(16,95,65)] hover:from-[rgb(16,89,60)] hover:to-[rgb(12,75,50)] text-white font-bold text-sm rounded-xl transition-all disabled:opacity-50 disabled:cursor-not-allowed shadow-lg shadow-primary/30 disabled:shadow-none hover:shadow-xl hover:scale-[1.02] active:scale-[0.98]"
- >
- {isRoundTrip && isOutbound ? 'Continue to Return Journey' : 'Continue to Passenger Details'}
-
-
- {!selectedCoachType && (
-
-
- Select a coach type to continue
-
- )}
+ {/* Note — only shown while unselected; once picked, the Continue
+ button below takes its place. */}
+ {!isSelected && (
+
+ Click to select this coach
+
+ )}
+
+ {/* Continue only appears on the card the user has actually picked —
+ a real nested button (the outer card is a div, not a button, so
+ this doesn't create invalid/ambiguous nested-button behavior). */}
+ {isSelected && (
+
{
+ e.stopPropagation();
+ handleSelect(classModal, isOutbound);
+ }}
+ className="mt-3 w-full flex items-center justify-center gap-2 px-4 py-2.5 bg-gradient-to-r from-[rgb(20,113,76)] to-[rgb(16,95,65)] hover:from-[rgb(16,89,60)] hover:to-[rgb(12,75,50)] text-white font-bold text-sm rounded-xl transition-all shadow-md shadow-primary/30 hover:shadow-lg active:scale-[0.98]"
+ >
+
+ {isRoundTrip && isOutbound
+ ? "Continue to Return Journey"
+ : "Continue to Passenger Details"}
+
+
+
+ )}
+
+
+ );
+ })}
-
+ ) : (
+
+
+
+
+
+ No coach types available for this journey
+
+
+ )}
+
-
-
- {t('help.title')}
- {t('help.subtitle')}
-
+
+ {/* Hero */}
+
+ Help & FAQs
+
+ Find answers about booking, pricing, payments, and more.
+
+
-
+ {/* Search */}
+
-
-
- {searchTerm ? (
- <>
- {filteredFAQs.length > 0 ? (
- filteredFAQs.map((faq) => (
-
-
- {faq.question}
-
-
{faq.answer}
-
- ))
- ) : (
-
- No FAQs found for "{searchTerm}"
-
- )}
- >
- ) : (
- faqCategories.map((category, catIdx) => (
-
-
{category.title}
- {category.items.map((item, itemIdx) => {
- const globalIdx = catIdx * 100 + itemIdx;
- const isOpen = openIndexes.includes(globalIdx);
+ {/* Content */}
+
+ {query ? (
+ searchResults.length > 0 ? (
+
+
+ {searchResults.length} result{searchResults.length !== 1 ? 's' : ''} for “{searchTerm}”
+
+ {searchResults.map(({ catTitle, item, key }) => (
+
toggle(key)}
+ />
+ ))}
+
+ ) : (
+
+
+
No results for “{searchTerm}”
+
+ )
+ ) : (
+
+ {FAQ_CATEGORIES.map((cat) => (
+
+
+ {cat.icon}
+
{cat.title}
+
+
+ {cat.items.map((item, i) => {
+ const key = `${cat.title}-${i}`;
return (
-
-
toggleFAQ(globalIdx)}
- >
- {item.question}
-
-
- {isOpen &&
{item.answer}
}
-
+
toggle(key)}
+ />
);
})}
- ))
- )}
+
+ ))}
-
+ )}
+
-
-
-
-
-
-
{t('help.help')}
-
{t('help.contact')}
-
Contact Support
+ {/* Contact CTA */}
+
+
+
+
-
-
- >
+
Still need help?
+
+ Our support team is available to assist you.
+
+
+ Contact Support
+
+
+
+
+ );
+}
+
+function FAQRow({
+ question,
+ answer,
+ badge,
+ isOpen,
+ onToggle,
+}: {
+ question: string;
+ answer: string;
+ badge?: string;
+ isOpen: boolean;
+ onToggle: () => void;
+}) {
+ return (
+
+
+
+ {badge && (
+
+ {badge}
+
+ )}
+
{question}
+
+
+
+ {isOpen && (
+
+ {answer}
+
+ )}
+
);
}
diff --git a/apps/edr-passenger-web/portal/src/components/Footer.tsx b/apps/edr-passenger-web/portal/src/components/Footer.tsx
index 8d1aa1394..8d4e36a3f 100644
--- a/apps/edr-passenger-web/portal/src/components/Footer.tsx
+++ b/apps/edr-passenger-web/portal/src/components/Footer.tsx
@@ -1,7 +1,6 @@
'use client';
import { Mail, Phone, MapPin } from 'lucide-react';
-import Link from 'next/link';
import { useLanguage, getTranslation, Language } from '@/lib/i18n';
import { useEffect, useState } from 'react';
@@ -21,83 +20,27 @@ export function Footer() {