implement PayNowButton component and integrate payment functionality in booking rows

This commit is contained in:
Marshal
2026-06-17 13:50:57 +00:00
parent 7eb6228d31
commit c89f7dcc11
5 changed files with 181 additions and 52 deletions

View File

@@ -721,27 +721,37 @@ export class TrainSchedulingService {
: null; : null;
if (route) { if (route) {
const origin = route.originYard; // `route.milestones` is the complete ordered corridor and already includes
const destination = route.destinationYard; // 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( const milestones = [...(route.milestones ?? [])].sort(
(a: RouteMilestone, b: RouteMilestone) => a.sequenceNo - b.sequenceNo, (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({ stations.push({
sequenceNo: 0, sequenceNo: 0,
yardId: route.originYardId, yardId: route.originYardId,
label: origin?.label ?? origin?.code ?? 'Origin', label: origin?.label ?? origin?.code ?? 'Origin',
code: origin?.code ?? '', 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({ stations.push({
sequenceNo: milestones.length + 1, sequenceNo: 1,
yardId: route.destinationYardId, yardId: route.destinationYardId,
label: destination?.label ?? destination?.code ?? 'Destination', label: destination?.label ?? destination?.code ?? 'Destination',
code: destination?.code ?? '', code: destination?.code ?? '',
@@ -775,8 +785,16 @@ export class TrainSchedulingService {
const stations = await this.buildScheduleStations(schedule); const stations = await this.buildScheduleStations(schedule);
const events = await this.trainCheckpointEventsRepository.findBySchedule(scheduleId); 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 const currentSequenceNo = events.length
? Math.max(...events.map((e) => e.sequenceNo)) ? Math.max(...events.map(resolvedSeq))
: -1; : -1;
return { return {
@@ -802,7 +820,7 @@ export class TrainSchedulingService {
currentSequenceNo, currentSequenceNo,
checkpoints: events.map((e) => ({ checkpoints: events.map((e) => ({
id: e.id, id: e.id,
sequenceNo: e.sequenceNo, sequenceNo: resolvedSeq(e),
yardId: e.yardId, yardId: e.yardId,
label: e.yard?.label ?? e.yard?.code ?? null, label: e.yard?.label ?? e.yard?.code ?? null,
kind: e.kind, kind: e.kind,

View File

@@ -2,6 +2,7 @@ import { Box, Group, Stack, Text } from "@mantine/core";
import { memo } from "react"; import { memo } from "react";
import { ACTION_PROPS, STATUS_CONFIG, cv } from "../constants"; import { ACTION_PROPS, STATUS_CONFIG, cv } from "../constants";
import { Stepper } from "./Stepper"; import { Stepper } from "./Stepper";
import { PayNowButton } from "@/pages/bookings/payments/PayNowButton";
interface BookingRowProps { interface BookingRowProps {
booking: any; booking: any;
@@ -18,6 +19,10 @@ export const BookingRow = memo(function BookingRow({
const Icon = cfg.icon; const Icon = cfg.icon;
const AIcon = cfg.action.icon; const AIcon = cfg.action.icon;
const ap = ACTION_PROPS[cfg.action.kind]; 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 origin = booking.originYard?.label ?? booking.originYard?.code ?? "—";
const dest = const dest =
booking.destinationYard?.label ?? booking.destinationYard?.code ?? "—"; booking.destinationYard?.label ?? booking.destinationYard?.code ?? "—";
@@ -78,25 +83,29 @@ export const BookingRow = memo(function BookingRow({
{cfg.badgeLabel} {cfg.badgeLabel}
</Text> </Text>
</Group> </Group>
<Group {canPay ? (
gap={5} <PayNowButton booking={booking} size="sm" />
align="center" ) : (
px={15} <Group
py={8} gap={5}
bg={ap.bg} align="center"
bd={ap.bd} px={15}
className="cursor-pointer rounded-[9px]" py={8}
> bg={ap.bg}
<Text fz={13} fw={700} c={ap.c}> bd={ap.bd}
{cfg.action.label} className="cursor-pointer rounded-[9px]"
</Text> >
{AIcon && ( <Text fz={13} fw={700} c={ap.c}>
<AIcon {cfg.action.label}
size={15} </Text>
color={ap.c === "white" ? "#fff" : cv("edr-text")} {AIcon && (
/> <AIcon
)} size={15}
</Group> color={ap.c === "white" ? "#fff" : cv("edr-text")}
/>
)}
</Group>
)}
</Stack> </Stack>
</Group> </Group>
</Box> </Box>

View File

@@ -21,7 +21,6 @@ import type { LucideIcon } from "lucide-react";
import { import {
ArrowRight, ArrowRight,
CheckCircle2, CheckCircle2,
CreditCard,
FileEdit, FileEdit,
LayoutList, LayoutList,
MoreVertical, MoreVertical,
@@ -34,6 +33,7 @@ import {
} from "lucide-react"; } from "lucide-react";
import { ShipmentTrackingModal } from "./tracking/ShipmentTrackingModal"; import { ShipmentTrackingModal } from "./tracking/ShipmentTrackingModal";
import { PayNowButton } from "./payments/PayNowButton";
// Bookings that have left (or are leaving) the yard can be tracked live. // Bookings that have left (or are leaving) the yard can be tracked live.
const TRACKABLE_STATUSES = new Set([ const TRACKABLE_STATUSES = new Set([
@@ -165,14 +165,13 @@ function StatusBadge({ status }: { status: string }) {
// ── Context-sensitive action button ─────────────────────────────────────────── // ── Context-sensitive action button ───────────────────────────────────────────
function PrimaryAction({ function PrimaryAction({
status, booking,
id,
onNavigate, onNavigate,
}: { }: {
status: string; booking: Freight.IBooking;
id: string;
onNavigate: (path: string) => void; onNavigate: (path: string) => void;
}) { }) {
const { status, id } = booking;
const go = () => onNavigate(`/bookings/${id}`); const go = () => onNavigate(`/bookings/${id}`);
if (status === "DRAFT") { if (status === "DRAFT") {
return ( return (
@@ -189,20 +188,8 @@ function PrimaryAction({
</Button> </Button>
); );
} }
if (status === "SELECTED_FOR_BATCH") { if (status === "SELECTED_FOR_BATCH" && booking.paymentStatus !== "PAID") {
return ( return <PayNowButton booking={booking} />;
<Button
size="xs"
radius="md"
fw={700}
fz={13}
leftSection={<CreditCard size={14} />}
color="edr-green"
onClick={go}
>
Pay now
</Button>
);
} }
return ( return (
<Button size="xs" radius="md" variant="default" fw={600} fz={13} onClick={go}> <Button size="xs" radius="md" variant="default" fw={600} fz={13} onClick={go}>
@@ -501,7 +488,7 @@ export default function MyBookings() {
Track Track
</Button> </Button>
)} )}
<PrimaryAction status={booking.status} id={booking.id} onNavigate={navigate} /> <PrimaryAction booking={booking} onNavigate={navigate} />
<Menu position="bottom-end" withinPortal shadow="md" radius="md"> <Menu position="bottom-end" withinPortal shadow="md" radius="md">
<Menu.Target> <Menu.Target>
<ActionIcon variant="transparent" size={30} radius="md" aria-label="More options"> <ActionIcon variant="transparent" size={30} radius="md" aria-label="More options">

View File

@@ -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 (
<>
<Button
size={size}
radius="md"
fw={700}
fz={13}
color="edr-green"
fullWidth={fullWidth}
leftSection={<CreditCard size={14} />}
onClick={(e) => {
// Don't let a surrounding row-click handler fire.
e.stopPropagation();
pay.open();
}}
>
{label}
</Button>
<PaymentMethodModal
opened={pay.modalOpen}
onClose={pay.close}
amountLabel={pricing ? priceTotal(pricing) : undefined}
currency={pricing?.currency ?? booking.paymentCurrency}
processing={pay.processing}
error={pay.error}
onConfirm={pay.confirm}
/>
</>
);
}

View File

@@ -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),
};
}