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 8f44665ad..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 { @@ -802,7 +820,7 @@ export class TrainSchedulingService { 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/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/bookings/MyBookings.tsx b/apps/edr-freight-web/portal/src/pages/bookings/MyBookings.tsx index efe2f6b60..2ce56cf1b 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/MyBookings.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/MyBookings.tsx @@ -21,7 +21,6 @@ import type { LucideIcon } from "lucide-react"; import { ArrowRight, CheckCircle2, - CreditCard, FileEdit, LayoutList, MoreVertical, @@ -34,6 +33,7 @@ import { } 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([ @@ -165,14 +165,13 @@ 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 ( @@ -189,20 +188,8 @@ function PrimaryAction({ ); } - if (status === "SELECTED_FOR_BATCH") { - return ( - - ); + if (status === "SELECTED_FOR_BATCH" && booking.paymentStatus !== "PAID") { + return ; } return ( )} - + 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), + }; +}