mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-09 03:38:17 +00:00
feat(bookings): add Additional Payments tab, add-charge modal, portal pay
Backoffice: new tab on the booking detail page (?tab=additional-charges, already linked from the row menu) with an Add Charge modal — Save draft or Send to customer. Portal: charges panel on the booking detail page, Pay now per charge via the existing OTP/CBE-bill payment flow. Also fixes: GET /bookings/:id/additional-charges was returning DRAFT charges to the customer (hidden client-side only) — now filtered server-side per caller.
This commit is contained in:
@@ -39,6 +39,7 @@ import {
|
||||
ConsolidationWaitingBanner,
|
||||
} from "./components/Notices";
|
||||
import { BookingPaymentPanel } from "./components/BookingPaymentPanel";
|
||||
import { AdditionalChargesPanel } from "./components/AdditionalChargesPanel";
|
||||
import { HeaderButton, PageHeader } from "./components/PageHeader";
|
||||
import { PaymentMethodModal } from "./components/PaymentMethodModal";
|
||||
import { ScheduleCard } from "./components/ScheduleCard";
|
||||
@@ -326,6 +327,7 @@ export function ReadonlyBookingView({
|
||||
paying={pay.processing}
|
||||
showCountdown={showCountdown}
|
||||
/>
|
||||
<AdditionalChargesPanel bookingId={booking.id} />
|
||||
<ScheduleCard
|
||||
booking={booking}
|
||||
title="Consignment & Schedule"
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import { useState } from "react";
|
||||
import { Badge, Box, Button, Group, Stack, Text } from "@mantine/core";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { CreditCard } from "lucide-react";
|
||||
|
||||
import type { Freight } from "@edr/types";
|
||||
|
||||
import { bookingsService } from "@/services/bookings.service";
|
||||
import { useInvoicePayment } from "@/hooks/useInvoicePayment";
|
||||
import { ModalSafeWrapper } from "@/components/customer-actions/ModalSafeWrapper";
|
||||
import { PaymentMethodModal } from "./PaymentMethodModal";
|
||||
import { CardTitle, SectionCard } from "./layout";
|
||||
|
||||
const STATUS_META: Record<Freight.AdditionalChargeStatus, { label: string; color: string }> = {
|
||||
DRAFT: { label: "Draft", color: "gray" },
|
||||
SENT: { label: "Awaiting payment", color: "#B07D14" },
|
||||
PAID: { label: "Paid", color: "#0A6F4D" },
|
||||
CANCELLED: { label: "Cancelled", color: "red" },
|
||||
};
|
||||
|
||||
/**
|
||||
* Ad-hoc extra charges EDR has raised against this booking — separate from the
|
||||
* freight invoice on `BookingPaymentPanel`. Only ever shows charges already
|
||||
* SENT (or settled) — a DRAFT charge isn't visible to the customer yet.
|
||||
*/
|
||||
export function AdditionalChargesPanel({ bookingId }: { bookingId: string }) {
|
||||
const { data: charges = [] } = useQuery({
|
||||
queryKey: ["additional-charges", bookingId],
|
||||
queryFn: () => bookingsService.getAdditionalCharges(bookingId),
|
||||
});
|
||||
|
||||
const visible = charges.filter((c) => c.status !== "DRAFT");
|
||||
if (visible.length === 0) return null;
|
||||
|
||||
return (
|
||||
<SectionCard p={22}>
|
||||
<CardTitle>Additional charges</CardTitle>
|
||||
<Stack gap={12} mt={12}>
|
||||
{visible.map((charge) => (
|
||||
<ChargeRow key={charge.id} charge={charge} />
|
||||
))}
|
||||
</Stack>
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
|
||||
function ChargeRow({ charge }: { charge: Freight.AdditionalCharge }) {
|
||||
const meta = STATUS_META[charge.status];
|
||||
return (
|
||||
<Box
|
||||
p={14}
|
||||
style={{ borderRadius: 10, border: "1px solid #EEF2F6" }}
|
||||
>
|
||||
<Group justify="space-between" align="flex-start" wrap="nowrap">
|
||||
<Box style={{ minWidth: 0 }}>
|
||||
<Text fz="13.5px" fw={700} c="#10202F">
|
||||
{charge.reason}
|
||||
</Text>
|
||||
<Text fz="12px" c="#9AA8B5" mt={2}>
|
||||
{charge.amount.toLocaleString(undefined, { minimumFractionDigits: 2 })}{" "}
|
||||
{charge.currency}
|
||||
{charge.paymentReference ? ` · ref ${charge.paymentReference}` : ""}
|
||||
</Text>
|
||||
</Box>
|
||||
<Badge
|
||||
radius="sm"
|
||||
variant="light"
|
||||
styles={{ root: { backgroundColor: `${meta.color}22`, color: meta.color } }}
|
||||
>
|
||||
{meta.label}
|
||||
</Badge>
|
||||
</Group>
|
||||
|
||||
{charge.status === "SENT" && charge.invoiceId && (
|
||||
<ChargePayButton invoiceId={charge.invoiceId} amount={charge.amount} currency={charge.currency} />
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function ChargePayButton({
|
||||
invoiceId,
|
||||
amount,
|
||||
currency,
|
||||
}: {
|
||||
invoiceId: string;
|
||||
amount: number;
|
||||
currency: string;
|
||||
}) {
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const flow = useInvoicePayment();
|
||||
|
||||
const close = () => {
|
||||
if (!flow.processing) {
|
||||
setModalOpen(false);
|
||||
flow.reset();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ModalSafeWrapper>
|
||||
<Button
|
||||
mt={10}
|
||||
size="xs"
|
||||
radius="md"
|
||||
fw={700}
|
||||
color="edr-green"
|
||||
leftSection={<CreditCard size={14} />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setModalOpen(true);
|
||||
}}
|
||||
>
|
||||
Pay now
|
||||
</Button>
|
||||
<PaymentMethodModal
|
||||
opened={modalOpen}
|
||||
onClose={close}
|
||||
amountLabel={`${amount.toLocaleString(undefined, { minimumFractionDigits: 2 })} ${currency}`}
|
||||
currency={currency}
|
||||
processing={flow.processing}
|
||||
error={flow.error}
|
||||
otp={flow.otp}
|
||||
bill={flow.bill}
|
||||
onConfirm={(method, payerAccount) => flow.pay(invoiceId, method, payerAccount)}
|
||||
/>
|
||||
</ModalSafeWrapper>
|
||||
);
|
||||
}
|
||||
@@ -324,6 +324,11 @@ export const bookingsService = {
|
||||
const { data } = await client.get(`/api/bookings/${id}/mile-summary`);
|
||||
return data.data;
|
||||
},
|
||||
/** Ad-hoc extra charges finance has raised against this booking. */
|
||||
getAdditionalCharges: async (id: string): Promise<Freight.AdditionalCharge[]> => {
|
||||
const { data } = await client.get(`/api/bookings/${id}/additional-charges`);
|
||||
return data.data;
|
||||
},
|
||||
assignCustomerTruck: async (
|
||||
id: string,
|
||||
payload: CustomerTruckAssignmentPayload,
|
||||
|
||||
Reference in New Issue
Block a user