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/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.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-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/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/MyBookings.tsx b/apps/edr-freight-web/portal/src/pages/bookings/MyBookings.tsx index 0ee19f95e..2ce56cf1b 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/MyBookings.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/MyBookings.tsx @@ -1,4 +1,4 @@ -import { useMemo } from "react"; +import { useMemo, useState } from "react"; import { Link, useNavigate } from "react-router-dom"; import { useQuery } from "@tanstack/react-query"; import { @@ -8,14 +8,44 @@ import { Card, Group, Menu, + Paper, + Select, + SimpleGrid, Stack, Text, + TextInput, ThemeIcon, Title, } from "@mantine/core"; -import { ArrowUpDown, Download, Filter, MoreVertical, Package, Plus } from "lucide-react"; +import type { LucideIcon } from "lucide-react"; +import { + ArrowRight, + CheckCircle2, + FileEdit, + LayoutList, + MoreVertical, + Package, + Plus, + Search, + Train, + Wallet, + X, +} from "lucide-react"; + +import { ShipmentTrackingModal } from "./tracking/ShipmentTrackingModal"; +import { PayNowButton } from "./payments/PayNowButton"; + +// Bookings that have left (or are leaving) the yard can be tracked live. +const TRACKABLE_STATUSES = new Set([ + "PAID", + "IN_TRANSIT", + "COMPLETED", + "DELIVERED", +]); import { api } from "@/services/api"; +import type { BookingListFilter } from "@/services/bookings.service"; +import { STATUS_CONFIG } from "@/pages/MyPortalPage/constants"; import type { Freight } from "@edr/types"; import { DataTable, @@ -24,25 +54,86 @@ import { usePagination, } from "@edr/ui-common"; -// ── Status badge ────────────────────────────────────────────────────────────── +// ── Status filter options (grouped by lifecycle) ────────────────────────────── -const STATUS_CONFIG: Record = { - 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") { - 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?.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 +380,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 +446,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 +471,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 +515,6 @@ export default function MyBookings() { }, ]; - const dataTableStatus = isLoading ? "loading" : isError ? "error" : "success"; - return ( @@ -305,81 +525,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..20f63812f 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,17 @@ -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 type { Freight } from "@edr/types"; @@ -61,10 +69,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} + /> + ))}
@@ -98,185 +103,201 @@ export function Step2ServiceType({ /> {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} + 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} + 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} + 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..387034ec5 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 @@ -19,6 +19,7 @@ import { type BookingFormValues, } from "./schema"; import { StepHeader } from "./shared"; +import { ClipboardCheck } from "lucide-react"; import type { Freight } from "@/types"; import type { GeneratePriceResponse } from "@/services/bookings.service"; @@ -154,6 +155,7 @@ export function Step8Review({ return ( } title="Review & Submit" description="Confirm your contract request before sending it for EDR staff review." /> diff --git a/apps/edr-freight-web/portal/src/pages/bookings/payments/PayNowButton.tsx b/apps/edr-freight-web/portal/src/pages/bookings/payments/PayNowButton.tsx new file mode 100644 index 000000000..ae1615b94 --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/bookings/payments/PayNowButton.tsx @@ -0,0 +1,61 @@ +import { Button, type ButtonProps } from "@mantine/core"; +import { CreditCard } from "lucide-react"; + +import { Freight } from "@edr/types"; + +import { PaymentMethodModal } from "../BookingDetailPage/components/PaymentMethodModal"; +import { priceTotal } from "../BookingDetailPage/utils"; +import { useBookingPayment } from "./useBookingPayment"; + +interface PayNowButtonProps { + booking: Freight.IBooking; + label?: string; + size?: ButtonProps["size"]; + fullWidth?: boolean; +} + +/** + * Self-contained "Pay now" action: shows the payment-method modal in place + * instead of navigating to the booking detail page. Drop it into list rows, + * cards, or anywhere a payable booking surfaces. + */ +export function PayNowButton({ + booking, + label = "Pay now", + size = "xs", + fullWidth, +}: PayNowButtonProps) { + const pay = useBookingPayment(booking.id); + const pricing = booking.pricingBreakdown; + + return ( + <> + + + + + ); +} diff --git a/apps/edr-freight-web/portal/src/pages/bookings/payments/useBookingPayment.ts b/apps/edr-freight-web/portal/src/pages/bookings/payments/useBookingPayment.ts new file mode 100644 index 000000000..ddaa05906 --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/bookings/payments/useBookingPayment.ts @@ -0,0 +1,54 @@ +import { useMutation } from "@tanstack/react-query"; +import { useState } from "react"; + +import { api } from "@/services/api"; +import { + paymentsService, + type PaymentMethod, +} from "@/services/payments.service"; + +/** + * Shared payment flow for a single booking: opens the method modal, fires + * POST /payments/initiate, and redirects the browser to the provider (or the + * fallback checkout page). Reused by the booking detail page, the booking list, + * and the home page so "Pay now" behaves identically everywhere. + */ +export function useBookingPayment(bookingId: string) { + const [modalOpen, setModalOpen] = useState(false); + + const mutation = useMutation({ + mutationFn: (method: PaymentMethod) => + api.payments.initiate.call({ bookingId, method }), + onSuccess: (data, method) => { + const redirectUrl = + data?.clientAction?.type === "REDIRECT" && data.clientAction.url + ? data.clientAction.url + : paymentsService.checkoutUrl({ bookingId, method }); + window.location.href = redirectUrl; + }, + }); + + const open = () => setModalOpen(true); + + const close = () => { + if (!mutation.isPending) { + setModalOpen(false); + mutation.reset(); + } + }; + + const error = mutation.isError + ? mutation.error instanceof Error + ? mutation.error.message + : "Could not start payment. Please try again." + : null; + + return { + modalOpen, + open, + close, + processing: mutation.isPending, + error, + confirm: (method: PaymentMethod) => mutation.mutate(method), + }; +} diff --git a/apps/edr-freight-web/portal/src/pages/bookings/tracking/ShipmentTrackingModal.tsx b/apps/edr-freight-web/portal/src/pages/bookings/tracking/ShipmentTrackingModal.tsx new file mode 100644 index 000000000..64ee63d5e --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/bookings/tracking/ShipmentTrackingModal.tsx @@ -0,0 +1,752 @@ +import { Box, Center, Group, Loader, Modal, Stack, Text } from "@mantine/core"; +import { useQuery } from "@tanstack/react-query"; +import { + AlertTriangle, + CheckCircle2, + Clock, + Flag, + MapPin, + PackageX, + RefreshCw, + Train, +} from "lucide-react"; + +import { api } from "@/services/api"; +import { Freight } from "@edr/types"; + +import { + checkpointKindLabel, + corridorProgress, + isArrived, + isDispatched, + shipmentStatusLabel, +} from "./trackingStages"; + +const GREEN = "#0EA371"; +const GREEN_DARK = "#0A6F4D"; +const ACCENT = "#F2A516"; +const INK = "#10202F"; +const MUTED = "#6B7C8E"; + +interface ShipmentTrackingModalProps { + opened: boolean; + onClose: () => void; + bookingId: string; + bookingReference: string; + originLabel?: string; + destinationLabel?: string; +} + +export function ShipmentTrackingModal({ + opened, + onClose, + bookingId, + bookingReference, + originLabel, + destinationLabel, +}: ShipmentTrackingModalProps) { + const { data, isLoading, isError, refetch, isFetching } = useQuery({ + ...api.bookings.tracking.queryOptions({ input: { id: bookingId } }), + enabled: opened && Boolean(bookingId), + refetchInterval: opened ? 30_000 : false, + }); + + const hasSchedule = data?.hasSchedule ?? false; + + return ( + +
refetch()} + refreshing={isFetching} + /> + + + {isLoading ? ( +
+ + + + Locating your train… + + +
+ ) : isError ? ( + refetch()} /> + ) : !hasSchedule ? ( + + ) : data ? ( + + + + + + ) : null} +
+ + ); +} + +// ── Header ──────────────────────────────────────────────────────────────────── + +function Header({ + bookingReference, + trainNumber, + status, + currentSequenceNo, + onClose, + onRefresh, + refreshing, +}: { + bookingReference: string; + trainNumber: string | null; + status: Freight.TrainScheduleStatus | null; + currentSequenceNo: number; + onClose: () => void; + onRefresh: () => void; + refreshing: boolean; +}) { + return ( + + + + + + + + + Live shipment tracking + + + {bookingReference} + + {trainNumber && ( + + Train {trainNumber} + + )} + + + + + + + + + + × + + + + + ); +} + +function IconButton({ + children, + onClick, + title, + spinning, +}: { + children: React.ReactNode; + onClick: () => void; + title: string; + spinning?: boolean; +}) { + return ( + + ); +} + +function HeaderStatusPill({ + status, + currentSequenceNo, +}: { + status: Freight.TrainScheduleStatus | null; + currentSequenceNo: number; +}) { + const arrived = isArrived(status); + const moving = isDispatched(status); + const bg = arrived + ? "rgba(14,163,113,0.22)" + : moving + ? "rgba(242,165,22,0.20)" + : "rgba(255,255,255,0.12)"; + const dot = arrived ? "#5BE3B0" : moving ? ACCENT : "#CBD5E1"; + return ( + + + + {shipmentStatusLabel(status, currentSequenceNo)} + + + ); +} + +// ── Summary bar (ETA / departure / arrival) ──────────────────────────────────── + +function SummaryBar({ data }: { data: Freight.IBookingTracking }) { + const arrived = isArrived(data.scheduleStatus); + const items: Array<{ label: string; value: string; accent?: boolean }> = [ + { + label: "Departed", + value: fmtTime(data.actualDepartureAt ?? data.scheduledDepartureAt), + }, + { + label: arrived ? "Arrived" : "Est. arrival", + value: fmtTime(data.actualArrivalAt ?? data.scheduledArrivalAt), + accent: !arrived, + }, + { + label: "Stations", + value: `${Math.max(0, data.currentSequenceNo + (data.currentSequenceNo >= 0 ? 1 : 0))} / ${data.stations.length}`, + }, + ]; + return ( + + {items.map((it, i) => ( + 0 ? "1px solid #EEF2F6" : undefined, + background: it.accent ? "#FEFBF3" : "#FBFCFD", + }} + > + + {it.label} + + + {it.value} + + + ))} + + ); +} + +// ── Corridor: stations + train marker ────────────────────────────────────────── + +function Corridor({ data }: { data: Freight.IBookingTracking }) { + const arrived = isArrived(data.scheduleStatus); + const moving = isDispatched(data.scheduleStatus); + const stations = data.stations; + const current = data.currentSequenceNo; + const progress = corridorProgress(stations.length, current, arrived); + + // Map sequenceNo → latest checkpoint at that station for captions. + const checkpointBySeq = new Map(); + for (const c of data.checkpoints) checkpointBySeq.set(c.sequenceNo, c); + + return ( + + + + + Where is your train + + + · {progress}% of the route + + + + {/* Horizontal rail */} + + {/* base rail */} + + {/* filled rail */} + + + {/* train marker riding the filled rail */} + + + {arrived ? : } + + + + {/* station nodes */} + + {stations.map((s, i) => { + const reached = arrived || (current >= 0 && i <= current); + const isCurrent = !arrived && i === current; + const isLast = i === stations.length - 1; + const cp = checkpointBySeq.get(s.sequenceNo); + return ( + + ); + })} + + + + ); +} + +function StationNode({ + label, + reached, + isCurrent, + isEndpoint, + arrivedHere, + time, + align, +}: { + label: string; + reached: boolean; + isCurrent: boolean; + isEndpoint: boolean; + arrivedHere: boolean; + time: string | null; + align: "left" | "center" | "right"; +}) { + const color = arrivedHere ? GREEN : isCurrent ? ACCENT : reached ? GREEN : "#CBD5E1"; + return ( + + + + + + {label} + + {time && ( + + {time} + + )} + + ); +} + +// ── Checkpoint feed ──────────────────────────────────────────────────────────── + +function CheckpointFeed({ data }: { data: Freight.IBookingTracking }) { + // Newest first. + const ordered = [...data.checkpoints].sort( + (a, b) => + new Date(b.occurredAt).getTime() - new Date(a.occurredAt).getTime(), + ); + + return ( + + + + + Journey log + + + + {ordered.length === 0 ? ( + + No checkpoints logged yet. Updates appear here as the train passes each + station along the corridor. + + ) : ( + + {ordered.map((cp, i) => { + const isLatest = i === 0; + const last = i === ordered.length - 1; + const Icon = + cp.kind === Freight.TrainCheckpointKind.Arrived + ? CheckCircle2 + : cp.kind === Freight.TrainCheckpointKind.Departed + ? Flag + : Train; + return ( + + + + + + {!last && ( + + )} + + + + + + {cp.label ?? "Checkpoint"} + + + {checkpointKindLabel(cp.kind)} + + {isLatest && ( + + Latest + + )} + + {cp.note && ( + + {cp.note} + + )} + + {fmtTime(cp.occurredAt)} + + + + ); + })} + + )} + + ); +} + +// ── Empty / error states ─────────────────────────────────────────────────────── + +function NotDispatchedState({ + origin, + destination, +}: { + origin: string; + destination: string; +}) { + return ( + + + + + + Not on the rails yet + + + Your shipment from {origin} to {destination} hasn't been + assigned to a train. Live tracking begins the moment it's dispatched and + starts moving along the corridor. + + + ); +} + +function ErrorState({ onRetry }: { onRetry: () => void }) { + return ( + + + + + + Couldn't load tracking + + + Something went wrong fetching your shipment status. + + + + ); +} + +// ── helpers ──────────────────────────────────────────────────────────────────── + +function fmtTime(iso?: string | null): string { + if (!iso) return "—"; + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return "—"; + return d.toLocaleString(undefined, { + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }); +} + +// keyframes (injected once) +if ( + typeof document !== "undefined" && + !document.getElementById("edr-tracking-kf") +) { + const style = document.createElement("style"); + style.id = "edr-tracking-kf"; + style.textContent = ` +@keyframes edr-spin { to { transform: rotate(360deg); } } +@keyframes edr-pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.35; } } +@keyframes edr-bob { 0%,100% { transform: translateY(0); } 50% { transform: translateY(-3px); } } +`; + document.head.appendChild(style); +} diff --git a/apps/edr-freight-web/portal/src/pages/bookings/tracking/trackingStages.ts b/apps/edr-freight-web/portal/src/pages/bookings/tracking/trackingStages.ts new file mode 100644 index 000000000..4acbcec8a --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/bookings/tracking/trackingStages.ts @@ -0,0 +1,64 @@ +import { Freight } from "@edr/types"; + +const { TrainScheduleStatus } = Freight; + +export function isArrived( + status?: Freight.TrainScheduleStatus | null, +): boolean { + return status === TrainScheduleStatus.Arrived; +} + +export function isDispatched( + status?: Freight.TrainScheduleStatus | null, +): boolean { + return status === TrainScheduleStatus.Dispatched; +} + +/** Human label for the schedule status, from the rider's point of view. */ +export function shipmentStatusLabel( + status?: Freight.TrainScheduleStatus | null, + currentSequenceNo = -1, +): string { + switch (status) { + case TrainScheduleStatus.Arrived: + return "Arrived"; + case TrainScheduleStatus.Dispatched: + return currentSequenceNo <= 0 ? "Departed" : "In transit"; + case TrainScheduleStatus.Scheduled: + return "Scheduled"; + case TrainScheduleStatus.Cancelled: + return "Cancelled"; + case TrainScheduleStatus.Draft: + return "Preparing"; + default: + return "Not dispatched"; + } +} + +/** + * 0–100 progress across the corridor, derived from how many stations the train + * has reached. Arrived → 100. Not departed → 0. + */ +export function corridorProgress( + stationCount: number, + currentSequenceNo: number, + arrived: boolean, +): number { + if (arrived) return 100; + if (stationCount <= 1 || currentSequenceNo < 0) return 0; + const lastSeq = stationCount - 1; + return Math.round((Math.min(currentSequenceNo, lastSeq) / lastSeq) * 100); +} + +/** Caption for a checkpoint kind. */ +export function checkpointKindLabel(kind: Freight.TrainCheckpointKind): string { + switch (kind) { + case Freight.TrainCheckpointKind.Departed: + return "Departed"; + case Freight.TrainCheckpointKind.Arrived: + return "Arrived"; + case Freight.TrainCheckpointKind.Passed: + default: + return "Passed"; + } +} diff --git a/apps/edr-freight-web/portal/src/pages/payments/PaymentFailurePage.tsx b/apps/edr-freight-web/portal/src/pages/payments/PaymentFailurePage.tsx new file mode 100644 index 000000000..6b577ac06 --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/payments/PaymentFailurePage.tsx @@ -0,0 +1,43 @@ +import { XCircle } from "lucide-react"; +import { useNavigate } from "react-router-dom"; +import { Button } from "@edr/ui-common"; + +/** + * Public page the payment provider redirects the browser to after a failed or + * cancelled payment (PAYMENT_FAILURE_URL). Generic — it explains nothing was + * charged and sends the customer back to their bookings to retry from "Pay now". + */ +export default function PaymentFailurePage() { + const navigate = useNavigate(); + + return ( +
+
+
+
+ +
+

+ Payment was not completed +

+

+ Your payment didn't go through and you haven't been charged. You can + try again from your booking using "Pay now". +

+
+ + +
+
+
+
+ ); +} diff --git a/apps/edr-freight-web/portal/src/pages/payments/PaymentSuccessPage.tsx b/apps/edr-freight-web/portal/src/pages/payments/PaymentSuccessPage.tsx new file mode 100644 index 000000000..4b2d766e7 --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/payments/PaymentSuccessPage.tsx @@ -0,0 +1,43 @@ +import { CheckCircle2 } from "lucide-react"; +import { useNavigate } from "react-router-dom"; +import { Button } from "@edr/ui-common"; + +/** + * Public page the payment provider redirects the browser to after a successful + * payment (PAYMENT_RETURN_URL). Generic — it confirms success and points the + * customer back to their bookings, where the booking reflects the paid state. + */ +export default function PaymentSuccessPage() { + const navigate = useNavigate(); + + return ( +
+
+
+
+ +
+

+ Payment successful +

+

+ Thank you — your payment has been received. Your booking will be + updated shortly and is now confirmed for scheduling. +

+
+ + +
+
+
+
+ ); +} diff --git a/apps/edr-freight-web/portal/src/services/api.ts b/apps/edr-freight-web/portal/src/services/api.ts index 32ef5bbc0..6d93d11e2 100644 --- a/apps/edr-freight-web/portal/src/services/api.ts +++ b/apps/edr-freight-web/portal/src/services/api.ts @@ -141,6 +141,12 @@ export const api = { ({ id }) => bookingsService.get(id), ), + tracking: endpoint<{ id: string }, Freight.IBookingTracking>( + "bookings", + "tracking", + ({ id }) => bookingsService.tracking(id), + ), + create: endpoint( "bookings", "create", diff --git a/apps/edr-freight-web/portal/src/services/bookings.service.ts b/apps/edr-freight-web/portal/src/services/bookings.service.ts index 9933fa227..4f4154faf 100644 --- a/apps/edr-freight-web/portal/src/services/bookings.service.ts +++ b/apps/edr-freight-web/portal/src/services/bookings.service.ts @@ -54,6 +54,8 @@ export interface SignContractPayload { export interface BookingListFilter { status?: string; + /** Comma-separated statuses (overrides `status` when set). */ + statuses?: string; page?: number; pageSize?: number; sortBy?: string; @@ -71,6 +73,10 @@ export const bookingsService = { const { data } = await client.get(`/api/bookings/${id}`); return data.data; }, + tracking: async (id: string): Promise => { + const { data } = await client.get(`/api/bookings/${id}/tracking`); + return data.data; + }, create: async (payload: CreateBookingPayload): Promise => { const { data } = await client.post("/api/bookings", payload); return data.data.booking; diff --git a/packages/types/src/freight/index.ts b/packages/types/src/freight/index.ts index 6672f95e9..fd300b184 100644 --- a/packages/types/src/freight/index.ts +++ b/packages/types/src/freight/index.ts @@ -265,6 +265,56 @@ export interface IConsignment extends BaseEntity { destinationStation: string; } +/** One station along the train's corridor (origin → milestones → destination). */ +export interface ITrackingStation { + sequenceNo: number; + yardId: string; + label: string; + code: string; +} + +/** A logged checkpoint as the train passes a station. */ +export interface ITrackingCheckpoint { + id: string; + sequenceNo: number; + yardId: string; + label: string | null; + kind: TrainCheckpointKind; + occurredAt: string; + note: string | null; +} + +/** + * Customer-facing shipment tracking payload for a single booking, derived from + * the train schedule the booking is assigned to and the live checkpoint log. + * + * `hasSchedule` is false when the booking has not been assigned to a train yet + * (still pre-dispatch) — the UI shows a "not on the rails yet" state. + */ +export interface IBookingTracking { + bookingId: string; + bookingReference: string; + hasSchedule: boolean; + scheduleId: string | null; + trainNumber: string | null; + /** Operational status of the assigned schedule (DRAFT/SCHEDULED/DISPATCHED/ARRIVED). */ + scheduleStatus: TrainScheduleStatus | null; + direction: string | null; + origin: string | null; + destination: string | null; + /** Ordered stations forming the corridor. */ + stations: ITrackingStation[]; + /** Logged checkpoints, ordered by sequence then time. */ + checkpoints: ITrackingCheckpoint[]; + /** Highest reached station sequence (−1 = not departed). */ + currentSequenceNo: number; + actualDepartureAt: string | null; + actualArrivalAt: string | null; + /** Planned departure/arrival from the schedule, used as ETA hints. */ + scheduledDepartureAt: string | null; + scheduledArrivalAt: string | null; +} + export interface IYard extends BaseEntity { code: string; label: string;