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 7bd38c935..7857d7c7e 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -43,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"; @@ -52,13 +51,14 @@ import { FreightPermissionKeyMigrationSeeder } from "./seed/freight-permission-k import { DemoFreightDataSeeder } from "./seed/demo-freight-data.seeder"; //New Trains, Wagons, Container and Cargo management modules import { TrainsModule } from "./modules/trains/trains.module"; -import { WagonsModule } from "./modules/wagons/wagons.module"; -import { ContainersModule } from "./modules/container-management/containers.module"; -import { CargoesModule } from "./modules/cargoes/cargoes.module"; -import { RoutesModule } from "./modules/routes/routes.module"; -import { WarehousesModule } from "./modules/warehouses/warehouses.module"; -import { FacilitiesModule } from "./modules/facilities/facilities.module"; -import { OverviewModule } from "./modules/overview/overview.module"; +import { WagonsModule } from './modules/wagons/wagons.module'; +import { ContainersModule } from './modules/container-management/containers.module'; +import { CargoesModule } from './modules/cargoes/cargoes.module'; +import { RoutesModule } from './modules/routes/routes.module'; +import { WarehousesModule } from './modules/warehouses/warehouses.module'; +import { FacilitiesModule } from './modules/facilities/facilities.module'; +import { OverviewModule } from './modules/overview/overview.module'; +import { VehiclesModule } from './modules/vehicles/vehicles.module'; @Module({ imports: [ @@ -117,12 +117,12 @@ import { OverviewModule } from "./modules/overview/overview.module"; FacilitiesModule, WarehousesModule, OverviewModule, + VehiclesModule, ], providers: [ EdrOrgSeeder, DemoUsersSeeder, FreightStaffUsersSeeder, - DemoBookingsSeeder, PricingDataSeeder, FileUploadSettingsSeeder, FreightPermissionKeyMigrationSeeder, @@ -137,11 +137,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, ) { } @@ -152,13 +147,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/migrations/1770000000000-CreateVehiclesTable.ts b/apps/edr-freight-api/src/migrations/1770000000000-CreateVehiclesTable.ts new file mode 100644 index 000000000..2cf09c9f5 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1770000000000-CreateVehiclesTable.ts @@ -0,0 +1,39 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class CreateVehiclesTable1770000000000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + DO $$ + BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'vehicles' AND table_schema = 'freight') THEN + CREATE TABLE freight.vehicles ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + plate_number VARCHAR NOT NULL UNIQUE, + registration_number VARCHAR NOT NULL UNIQUE, + vehicle_type VARCHAR NOT NULL, + manufacturer VARCHAR NOT NULL, + model VARCHAR NOT NULL, + year INTEGER NOT NULL, + fuel_type VARCHAR NOT NULL, + capacity NUMERIC NOT NULL, + status VARCHAR DEFAULT 'ACTIVE' NOT NULL, + description TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL, + deleted_at TIMESTAMP NULL + ); + + CREATE INDEX idx_vehicles_plate_number ON freight.vehicles(plate_number); + CREATE INDEX idx_vehicles_registration_number ON freight.vehicles(registration_number); + CREATE INDEX idx_vehicles_status ON freight.vehicles(status); + CREATE INDEX idx_vehicles_vehicle_type ON freight.vehicles(vehicle_type); + CREATE INDEX idx_vehicles_manufacturer ON freight.vehicles(manufacturer); + END IF; + END $$; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS freight.vehicles CASCADE;`); + } +} diff --git a/apps/edr-freight-api/src/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..edda990de 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, 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..b32b16b32 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,16 @@ 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 { FilesService } from '../files/files.service'; import { MinioService } from '../minio/minio.service'; import { ContainerTypesService } from '../rule-engine/services/container-types.service'; @@ -52,6 +56,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 +152,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 +203,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 +216,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. */ @@ -575,6 +565,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 +578,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 +624,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/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/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts index 17184bf97..98b6d70c2 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 @@ -721,27 +721,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 +785,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 +808,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, diff --git a/apps/edr-freight-api/src/modules/vehicles/dto/create-vehicle.dto.ts b/apps/edr-freight-api/src/modules/vehicles/dto/create-vehicle.dto.ts new file mode 100644 index 000000000..fb5c4e92b --- /dev/null +++ b/apps/edr-freight-api/src/modules/vehicles/dto/create-vehicle.dto.ts @@ -0,0 +1,32 @@ +import { IsString, IsEnum, IsNumber, IsOptional } from 'class-validator'; +import { VehicleType, FuelType, VehicleStatus } from '../entities/vehicle.entity'; + +export class CreateVehicleDto { + @IsString() + plateNumber!: string; + + @IsEnum(VehicleType) + vehicleType!: VehicleType; + + @IsString() + manufacturer!: string; + + @IsString() + model!: string; + + @IsNumber() + year!: number; + + @IsEnum(FuelType) + fuelType!: FuelType; + + @IsNumber() + capacity!: number; + + @IsEnum(VehicleStatus) + status!: VehicleStatus; + + @IsOptional() + @IsString() + description?: string; +} diff --git a/apps/edr-freight-api/src/modules/vehicles/dto/update-vehicle.dto.ts b/apps/edr-freight-api/src/modules/vehicles/dto/update-vehicle.dto.ts new file mode 100644 index 000000000..953917b2f --- /dev/null +++ b/apps/edr-freight-api/src/modules/vehicles/dto/update-vehicle.dto.ts @@ -0,0 +1,4 @@ +import { PartialType } from '@nestjs/mapped-types'; +import { CreateVehicleDto } from './create-vehicle.dto'; + +export class UpdateVehicleDto extends PartialType(CreateVehicleDto) {} diff --git a/apps/edr-freight-api/src/modules/vehicles/entities/vehicle.entity.ts b/apps/edr-freight-api/src/modules/vehicles/entities/vehicle.entity.ts new file mode 100644 index 000000000..773e8051a --- /dev/null +++ b/apps/edr-freight-api/src/modules/vehicles/entities/vehicle.entity.ts @@ -0,0 +1,64 @@ +import { Entity, Column, Index } from 'typeorm'; +import { BaseEntity } from '@edr/api-common'; + +export enum VehicleType { + TRUCK = 'TRUCK', + VAN = 'VAN', + CAR = 'CAR', + BUS = 'BUS', + TRAILER = 'TRAILER', + TANKER = 'TANKER', + FLATBED = 'FLATBED', +} + +export enum FuelType { + PETROL = 'PETROL', + DIESEL = 'DIESEL', + ELECTRIC = 'ELECTRIC', + HYBRID = 'HYBRID', +} + +export enum VehicleStatus { + ACTIVE = 'ACTIVE', + MAINTENANCE = 'MAINTENANCE', + RETIRED = 'RETIRED', + OUT_OF_SERVICE = 'OUT_OF_SERVICE', +} + +@Entity({ name: 'vehicles', schema: 'freight' }) +@Index(['plateNumber']) +@Index(['registrationNumber']) +@Index(['status']) +@Index(['vehicleType']) +@Index(['manufacturer']) +export class Vehicle extends BaseEntity { + @Column({ name: 'plate_number', unique: true }) + plateNumber!: string; + + @Column({ name: 'registration_number', unique: true }) + registrationNumber!: string; + + @Column({ name: 'vehicle_type', type: 'varchar' }) + vehicleType!: VehicleType; + + @Column() + manufacturer!: string; + + @Column() + model!: string; + + @Column() + year!: number; + + @Column({ name: 'fuel_type', type: 'varchar' }) + fuelType!: FuelType; + + @Column() + capacity!: number; + + @Column({ name: 'status', type: 'varchar', default: VehicleStatus.ACTIVE }) + status!: VehicleStatus; + + @Column({ type: 'text', nullable: true }) + description!: string | null; +} diff --git a/apps/edr-freight-api/src/modules/vehicles/vehicles.controller.ts b/apps/edr-freight-api/src/modules/vehicles/vehicles.controller.ts new file mode 100644 index 000000000..24ff2d022 --- /dev/null +++ b/apps/edr-freight-api/src/modules/vehicles/vehicles.controller.ts @@ -0,0 +1,74 @@ +import { + Controller, + Get, + Post, + Patch, + Delete, + Param, + Body, + Query, + ParseUUIDPipe, +} from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { FleetManage, FleetView } from '../../common/booking-guards'; +import { VehiclesService } from './vehicles.service'; +import { CreateVehicleDto } from './dto/create-vehicle.dto'; +import { UpdateVehicleDto } from './dto/update-vehicle.dto'; + +@ApiTags('vehicles') +@ApiBearerAuth() +@Controller('vehicles') +@FleetView() +export class VehiclesController { + constructor(private readonly vehiclesService: VehiclesService) {} + + @Post() + @FleetManage() + @ApiOperation({ summary: 'Create a new vehicle' }) + create(@Body() createVehicleDto: CreateVehicleDto) { + return this.vehiclesService.create(createVehicleDto); + } + + @Get() + @ApiOperation({ summary: 'Get all vehicles with filters' }) + findAll( + @Query('search') search?: string, + @Query('status') status?: string, + @Query('page') page?: string, + @Query('limit') limit?: string, + @Query('sortBy') sortBy?: string, + @Query('sortOrder') sortOrder?: 'ASC' | 'DESC', + ) { + return this.vehiclesService.findAll({ + search, + status: status as any, + page: page ? parseInt(page) : undefined, + limit: limit ? parseInt(limit) : undefined, + sortBy, + sortOrder, + }); + } + + @Get(':id') + @ApiOperation({ summary: 'Get vehicle by id' }) + findOne(@Param('id', ParseUUIDPipe) id: string) { + return this.vehiclesService.findById(id); + } + + @Patch(':id') + @FleetManage() + @ApiOperation({ summary: 'Update a vehicle' }) + update( + @Param('id', ParseUUIDPipe) id: string, + @Body() updateVehicleDto: UpdateVehicleDto, + ) { + return this.vehiclesService.update(id, updateVehicleDto); + } + + @Delete(':id') + @FleetManage() + @ApiOperation({ summary: 'Delete a vehicle' }) + remove(@Param('id', ParseUUIDPipe) id: string) { + return this.vehiclesService.remove(id); + } +} diff --git a/apps/edr-freight-api/src/modules/vehicles/vehicles.module.ts b/apps/edr-freight-api/src/modules/vehicles/vehicles.module.ts new file mode 100644 index 000000000..07aa4bd2f --- /dev/null +++ b/apps/edr-freight-api/src/modules/vehicles/vehicles.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { Vehicle } from './entities/vehicle.entity'; +import { VehiclesService } from './vehicles.service'; +import { VehiclesController } from './vehicles.controller'; + +@Module({ + imports: [TypeOrmModule.forFeature([Vehicle])], + providers: [VehiclesService], + controllers: [VehiclesController], + exports: [VehiclesService], +}) +export class VehiclesModule {} diff --git a/apps/edr-freight-api/src/modules/vehicles/vehicles.repository.ts b/apps/edr-freight-api/src/modules/vehicles/vehicles.repository.ts new file mode 100644 index 000000000..9c5bad1e9 --- /dev/null +++ b/apps/edr-freight-api/src/modules/vehicles/vehicles.repository.ts @@ -0,0 +1,15 @@ +import { BaseRepository } from '@edr/api-common'; +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { Vehicle } from './entities/vehicle.entity'; + +@Injectable() +export class VehiclesRepository extends BaseRepository { + constructor( + @InjectRepository(Vehicle) + repository: Repository, + ) { + super(repository); + } +} diff --git a/apps/edr-freight-api/src/modules/vehicles/vehicles.service.ts b/apps/edr-freight-api/src/modules/vehicles/vehicles.service.ts new file mode 100644 index 000000000..12970a8a8 --- /dev/null +++ b/apps/edr-freight-api/src/modules/vehicles/vehicles.service.ts @@ -0,0 +1,109 @@ +import { Injectable, NotFoundException, ConflictException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { CreateVehicleDto } from './dto/create-vehicle.dto'; +import { UpdateVehicleDto } from './dto/update-vehicle.dto'; +import { Vehicle, VehicleStatus } from './entities/vehicle.entity'; + +@Injectable() +export class VehiclesService { + constructor( + @InjectRepository(Vehicle) + private readonly vehicleRepo: Repository, + ) {} + + async create(dto: CreateVehicleDto): Promise { + const existing = await this.vehicleRepo.findOne({ + where: { plateNumber: dto.plateNumber }, + }); + + if (existing) { + throw new ConflictException( + `Vehicle with plate number ${dto.plateNumber} already exists`, + ); + } + + const registrationNumber = `REG-${dto.vehicleType}-${Date.now()}`; + const vehicle = this.vehicleRepo.create({ + ...dto, + registrationNumber, + }); + + return this.vehicleRepo.save(vehicle); + } + + async findAll(query: { + search?: string; + status?: VehicleStatus | string; + page?: number; + limit?: number; + sortBy?: string; + sortOrder?: 'ASC' | 'DESC'; + } = {}): Promise<{ data: Vehicle[]; total: number; page: number; limit: number }> { + const page = query.page || 1; + const limit = query.limit || 10; + const skip = (page - 1) * limit; + + const where: any = {}; + if (query.status) where.status = query.status; + + let qb = this.vehicleRepo.createQueryBuilder('v'); + + if (query.search) { + qb = qb.where( + 'v.plateNumber ILIKE :search OR v.manufacturer ILIKE :search', + { search: `%${query.search}%` }, + ); + } + + if (query.status) { + qb = qb.andWhere('v.status = :status', { status: query.status }); + } + + const sortBy = ['plateNumber', 'status', 'year', 'createdAt'].includes( + query.sortBy ?? '', + ) + ? query.sortBy + : 'createdAt'; + const sortOrder = (query.sortOrder ?? 'DESC').toUpperCase(); + + const [data, total] = await qb + .orderBy(`v.${sortBy}`, sortOrder as 'ASC' | 'DESC') + .skip(skip) + .take(limit) + .getManyAndCount(); + + return { data, total, page, limit }; + } + + async findById(id: string): Promise { + const vehicle = await this.vehicleRepo.findOne({ where: { id } }); + if (!vehicle) { + throw new NotFoundException(`Vehicle ${id} not found`); + } + return vehicle; + } + + async update(id: string, dto: UpdateVehicleDto): Promise { + const vehicle = await this.findById(id); + + if (dto.plateNumber && dto.plateNumber !== vehicle.plateNumber) { + const existing = await this.vehicleRepo.findOne({ + where: { plateNumber: dto.plateNumber }, + }); + if (existing) { + throw new ConflictException( + `Vehicle with plate number ${dto.plateNumber} already exists`, + ); + } + } + + Object.assign(vehicle, dto); + return this.vehicleRepo.save(vehicle); + } + + async remove(id: string): Promise { + await this.findById(id); + await this.vehicleRepo.softDelete(id); + } +} diff --git a/apps/edr-freight-api/src/seed/demo-freight-data.seeder.ts b/apps/edr-freight-api/src/seed/demo-freight-data.seeder.ts index f4931f77b..b391d3f9e 100644 --- a/apps/edr-freight-api/src/seed/demo-freight-data.seeder.ts +++ b/apps/edr-freight-api/src/seed/demo-freight-data.seeder.ts @@ -42,8 +42,13 @@ export class DemoFreightDataSeeder { async run() { await this.dataSource.transaction(async (manager) => { - await this.seedWagons(manager); - await this.seedApprovalRules(manager); + // Demo freight data (wagons + approval rules) disabled — keep only the + // 4 staff users. The seeders are retained for easy re-enabling; flip + // SEED_DEMO_FREIGHT_DATA=true to run them. + if (process.env.SEED_DEMO_FREIGHT_DATA === 'true') { + await this.seedWagons(manager); + await this.seedApprovalRules(manager); + } await this.seedStaffUsers(manager); }); } diff --git a/apps/edr-freight-web/backoffice/src/pages/payments/PaymentsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/payments/PaymentsPage.tsx index 07e212b76..af92d38fd 100644 --- a/apps/edr-freight-web/backoffice/src/pages/payments/PaymentsPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/payments/PaymentsPage.tsx @@ -12,9 +12,11 @@ import { Text, TextInput, } from "@mantine/core"; +import { Badge as MantineBadge } from "@mantine/core"; import { CheckCircle2, CircleDollarSign, + LayoutGrid, Loader2, RotateCcw, Search, @@ -24,6 +26,7 @@ import { } from "lucide-react"; import Breadcrumbs from "@/components/ui/Breadcrumbs"; +import "@/components/overview/overview.css"; import { usePaymentList, usePaymentSummary } from "@/hooks/usePayments"; import type { PaymentMethod, @@ -39,11 +42,16 @@ import { } from "@edr/ui-common"; const STATUS_TABS = [ - { key: "all", label: "All", statuses: undefined as string | undefined }, - { key: "success", label: "Success", statuses: "success" }, - { key: "processing", label: "Processing", statuses: "processing,action-required" }, - { key: "failed", label: "Failed", statuses: "failed,canceled" }, - { key: "refunded", label: "Refunded", statuses: "refunded" }, + { key: "all", label: "All", statuses: undefined as string | undefined, icon: LayoutGrid }, + { key: "success", label: "Success", statuses: "success", icon: CheckCircle2 }, + { + key: "processing", + label: "Processing", + statuses: "processing,action-required", + icon: Loader2, + }, + { key: "failed", label: "Failed", statuses: "failed,canceled", icon: XCircle }, + { key: "refunded", label: "Refunded", statuses: "refunded", icon: RotateCcw }, ] as const; type StatusTabKey = (typeof STATUS_TABS)[number]["key"]; @@ -166,6 +174,20 @@ export default function PaymentsPage() { const val = (n?: number) => (summaryLoading ? "—" : (n ?? 0)); + const tabCounts: Record = { + all: + summary === undefined + ? undefined + : (summary.success ?? 0) + + (summary.processing ?? 0) + + (summary.failed ?? 0) + + (summary.refunded ?? 0), + success: summary?.success, + processing: summary?.processing, + failed: summary?.failed, + refunded: summary?.refunded, + }; + const columns: ColumnDef[] = [ { id: "order", @@ -274,13 +296,48 @@ export default function PaymentsPage() { setStatusTab((value as StatusTabKey) ?? "all"); setPagination({ pageIndex: 0, pageSize: pagination.pageSize }); }} + variant="pills" + color="green" + keepMounted={false} + classNames={{ list: "ov-tablist", tab: "ov-tab" }} > - {STATUS_TABS.map((t) => ( - - {t.label} - - ))} + {STATUS_TABS.map((t) => { + const isActive = statusTab === t.key; + const count = tabCounts[t.key]; + const Icon = t.icon; + return ( + } + rightSection={ + count !== undefined ? ( + + {count} + + ) : undefined + } + > + {t.label} + + ); + })} diff --git a/apps/edr-freight-web/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..a4c2c8553 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() { @@ -158,6 +160,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 */} }> 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/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")} = [ + { 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..5f1e4b615 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/logo.svg"; 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..c340dc845 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 @@ -163,6 +163,7 @@ export function ReadonlyBookingView({ booking }: { booking: Freight.IBooking }) } }} amountLabel={pricing ? priceTotal(pricing) : undefined} + currency={pricing?.currency ?? booking.paymentCurrency} processing={payMutation.isPending} error={ payMutation.isError 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/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..a778bf8e5 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,20 @@ 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 ?? "", consolidationEnabled: booking.allowConsolidation ?? false, + paymentCurrency: + booking.paymentCurrency === "ETB" ? "ETB" : "USD", + scheduledDate: booking.scheduledDate + ? new Date(booking.scheduledDate).toISOString().slice(0, 10) + : "", + trainScheduleId: (booking as { trainScheduleId?: string }).trainScheduleId ?? "", notes: "", containers: [], } as BookingFormInputValues; @@ -237,9 +265,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 +308,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 +342,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 +352,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, })); @@ -338,14 +376,9 @@ 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; @@ -369,10 +402,15 @@ 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, + trainScheduleId: data.trainScheduleId || undefined, contractType: data.contractType.toUpperCase() as CreateBookingPayload["contractType"], serviceTypeId: data.serviceTypeId, @@ -380,8 +418,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 +429,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) @@ -505,9 +542,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 +712,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..a2a32bd5e 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 @@ -385,64 +385,147 @@ export function StepScheduling({ form, referenceData }: StepSchedulingProps) { 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 }, - }} + size={520} + radius={18} + padding={0} + withCloseButton={false} + overlayProps={{ backgroundOpacity: 0.5, blur: 3 }} > - - - Choose a departure time + {/* Header */} + + + + + Available departures + + + + {selectedDayForModal + ? format(new Date(selectedDayForModal.dateString + "T00:00:00"), "EEEE, MMM d yyyy") + : ""} - {selectedDayForModal?.schedules.map((schedule) => ( - - ))} + }} + > + + + + + + + + {format(new Date(schedule.scheduleDate), "HH:mm")} + + + {schedule.trainNumber + ? `Train ${schedule.trainNumber}` + : `#${schedule.id.slice(0, 6)}`} + + + {/* capacity bar */} + + + + {remaining} / {max} wagons free + + 25 ? "edr-green.7" : "#C77F09"}> + {pct}% + + + + 25 + ? `linear-gradient(90deg, ${theme.colors["edr-green"][7]}, ${theme.colors["edr-green"][5]})` + : "#F2A516", + }} + /> + + + + + {isSelected && } + + + + ); + })}
@@ -561,36 +644,42 @@ function DayCell({ day: d, onDayClick, }: DayCellProps) { )}
- {/* Schedule times */} + {/* Availability marker — a dot + count, never the schedule list itself. */} {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 + + + + + {d.schedules.length} departure{d.schedules.length !== 1 ? "s" : ""} - )} - +
+
)} ); 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..f7545f042 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 @@ -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..dd5df7d9c 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, @@ -63,7 +63,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 +84,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 +96,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..ae7cddf4f 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,320 @@ 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 */} - ( -