mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 04:08:11 +00:00
implement PayNowButton component and integrate payment functionality in booking rows
This commit is contained in:
@@ -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,
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user