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/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 dceb23f2f..63e85c1c1 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"; @@ -99,7 +97,6 @@ import { DriversModule } from './modules/drivers/drivers.module'; TrainSchedulesModule, TrainSchedulingModule, SchedulingRescheduleModule, - CustomersModule, CompaniesModule, TrackingModule, BillingModule, @@ -128,7 +125,6 @@ import { DriversModule } from './modules/drivers/drivers.module'; EdrOrgSeeder, DemoUsersSeeder, FreightStaffUsersSeeder, - DemoBookingsSeeder, PricingDataSeeder, FileUploadSettingsSeeder, FreightPermissionKeyMigrationSeeder, @@ -143,11 +139,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, - private readonly batch14TestDataSeeder: Batch14TestDataSeeder, private readonly freightPermissionKeyMigrationSeeder: FreightPermissionKeyMigrationSeeder, private readonly demoFreightDataSeeder: DemoFreightDataSeeder, ) { } @@ -158,13 +149,11 @@ 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(); - await this.batch14TestDataSeeder.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/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/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/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index e76a9f5b7..63c09a67c 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"; @@ -552,6 +553,8 @@ const App = () => { } /> + } /> + } /> } />
-
+
diff --git a/apps/edr-freight-web/backoffice/src/constants/URLS.ts b/apps/edr-freight-web/backoffice/src/constants/URLS.ts index f4b5d4dcc..7b70f7d7e 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 ? ( - (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 && ( + + )} + + + + + {/* ── 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 && ( + + )} + + ) : ( + + {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)} + + . + + )} + + + + + + + + + ); +}; + +// ── 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/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) => ( +
+ Ethio Djibouti Railway +
+ + + + +
+
+
+
+ + {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 ( + <> + + + + +
+
+ + +
+ + +
+ +
+ +
+
+
+ {children} +
+
+
+ + +
+
+
+ + ); +} 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} + + ) : ( + + )} + + ); + })} + )} -
+ - {/* 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. + + + + + + + + ); } 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 ( - - - - - - - + +
+
+ EDR Freight +
+ +
+

Welcome back - - - Enter your credentials to access your portal - - - +

+

+ Enter your credentials to access your freight portal. +

+
- - - setMethod(v as LoginMethod)} - fullWidth - radius="md" - data={[ - { - label: ( - - - Email - - ), - value: "email", - }, - { - label: ( - - - Phone - - ), - value: "phone", - }, - ]} - /> - - {method === "email" ? ( - setIdentifier(e.target.value)} - required - disabled={loading} - /> - ) : ( - ) => - setCountryCode(e.target.value), - }} - phone={{ - value: phoneNumber, - onChange: (e: React.ChangeEvent) => - setPhoneNumber(e.target.value), - }} - /> - )} - - - - - Password - - +
+
+ + {error ? ( +
{error} - - )} +
+ ) : null} - + - - Don't have an account?{" "} - - - + +

+
- + ); } 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 - - +
+
+ EDR Freight +
+ +
+

+ Create account +

+

Register to access EDR Freight services. - - - +

+
- {error && ( - - {error} - - )} +
+
+
+ + + {errorText(errors.firstName?.en?.message)} +
+
+ + + {errorText(errors.lastName?.en?.message)} +
+
- - - - + + - - + {errorText(errors.email?.message)} +
- +
+ + +
+ + {ETHIOPIA_COUNTRY_CODE} + + { + event.target.value = event.target.value.replace(/\D/g, "").slice(0, 10); + }, + })} + /> +
+ {errorText(errors.phone?.message)} +
- - - - - {passwordValue.length > 0 && ( - +
+ +
+ + +
+ {errorText(errors.password?.message)} + {passwordValue.length > 0 ? ( +
{passwordRequirements.map((req) => { const met = req.test(passwordValue); return ( - - + - {met ? : } - - + {met ? : } + + {req.label} - - + +
); })} - - )} - +
+ ) : null} +
- +
+ +
+ + +
+ {errorText(errors.confirmPassword?.message)} +
-
+ ) : null} + + + {loading ? "Creating account..." : "Create Account"} + {!loading ? : null} + - +

Already have an 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} + + + ))} + + )} + + + + + + )} + + 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.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} )} - - - You'll be redirected to your provider to complete payment securely. - - + + + + Secured · you'll be redirected to your provider to pay + + + + + + + + ); } 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 ( ); } - if (status === "AWAITING_PAYMENT") { + if (status === "CHANGES_REQUESTED") { return ( ); } - if (status === "IN_TRANSIT") { - return ( - - ); + if (status === "SELECTED_FOR_BATCH" && booking.paymentStatus !== "PAID") { + return ; } return ( - ); } -// ── 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 && ( + + )} + - + 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. - - - - + + {/* ── Summary stat cards ──────────────────────────────────────── */} + + {STAT_CARDS.map((card) => ( + selectFilter(card.key)} + /> + ))} + + {/* ── Bookings table card ──────────────────────────────────────── */} - {/* Toolbar */} - - + + } + value={query} + onChange={(e) => setQuery(e.currentTarget.value)} + rightSection={ + query ? ( + setQuery("")} + > + + + ) : null + } + radius="md" + style={{ flex: 1, minWidth: 200, maxWidth: 340 }} + /> + ); } @@ -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) => ( - - ))} - -
); } @@ -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} + + + + + {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 || "—"} - -
- -
- ); - } - - 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 && ( + )} - - - - - - - - )} - - {/* 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"} - -
- -
-
-
+ Edit service options + + - {/* Notes */} - ( -