From 6e6d6329b69ea8bbdb3e01053eca7c8799f72e98 Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Thu, 20 Aug 2026 12:54:37 +0000 Subject: [PATCH] feat(bookings): add Additional Payments tab, add-charge modal, portal pay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../modules/bookings/bookings.controller.ts | 7 +- .../bookings/AdditionalPaymentsTab.tsx | 397 ++++++++++++++++++ .../bookings/BookingRequestDetailPage.tsx | 29 +- .../src/services/bookings.service.ts | 47 +++ .../BookingDetailPage/ReadonlyBookingView.tsx | 2 + .../components/AdditionalChargesPanel.tsx | 129 ++++++ .../portal/src/services/bookings.service.ts | 5 + 7 files changed, 613 insertions(+), 3 deletions(-) create mode 100644 apps/edr-freight-web/backoffice/src/components/bookings/AdditionalPaymentsTab.tsx create mode 100644 apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/AdditionalChargesPanel.tsx 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 1a9652656..e122a8800 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts @@ -1222,11 +1222,14 @@ export class BookingsController { @Param("id", ParseUUIDPipe) id: string, @CurrentUser() user: TCurrentUser, ) { - if (!hasFreightPermission(user, FREIGHT_PERMS.additionalCharges.view)) { + const isStaff = hasFreightPermission(user, FREIGHT_PERMS.additionalCharges.view); + if (!isStaff) { const booking = await this.bookingsService.findById(id); await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking); } - return this.additionalChargeService.list(id); + const charges = await this.additionalChargeService.list(id); + // A charge finance hasn't sent yet isn't the customer's to see. + return isStaff ? charges : charges.filter((c) => c.status !== "DRAFT"); } @Post(":id/additional-charges") diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/AdditionalPaymentsTab.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/AdditionalPaymentsTab.tsx new file mode 100644 index 000000000..38fda81e1 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/bookings/AdditionalPaymentsTab.tsx @@ -0,0 +1,397 @@ +import { useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { + Badge, + Box, + Button, + FileButton, + Group, + Loader, + Modal, + NumberInput, + Paper, + Select, + Stack, + Text, + Textarea, + Tooltip, +} from "@mantine/core"; +import { + Ban, + Download, + Eye, + FileText, + Plus, + Receipt, + Send, + Upload, +} from "lucide-react"; +import toast from "react-hot-toast"; +import type { Freight } from "@edr/types"; +import { isViewable } from "@edr/ui-common"; + +import { bookingsService } from "@/services/bookings.service"; +import { downloadBookingFile, fetchViewableFile } from "@/services/files.service"; +import { formatDateTime } from "@/lib/format"; +import { extractErrorMessage } from "@/utils/errorExtractor"; + +const CURRENCIES = ["ETB", "USD"]; + +const STATUS_META: Record = { + DRAFT: { label: "Draft", color: "gray" }, + SENT: { label: "Sent — unpaid", color: "orange" }, + PAID: { label: "Paid", color: "edr-green" }, + CANCELLED: { label: "Cancelled", color: "red" }, +}; + +export interface AdditionalPaymentsTabProps { + bookingId: string; + onViewFile: (file: { name: string; url: string }) => void; +} + +/** + * Ad-hoc extra charges finance raises against a booking — any number, free-text + * reason. Draft until sent; sending issues the payable invoice and notifies the + * customer (in-app + SMS + email). Settles the same way every invoice does. + */ +export function AdditionalPaymentsTab({ bookingId, onViewFile }: AdditionalPaymentsTabProps) { + const qc = useQueryClient(); + const [modalOpen, setModalOpen] = useState(false); + + const { data: charges, isLoading } = useQuery({ + queryKey: ["additional-charges", bookingId], + queryFn: () => bookingsService.getAdditionalCharges(bookingId), + }); + + const refresh = (next: Freight.AdditionalCharge[]) => + qc.setQueryData(["additional-charges", bookingId], next); + const onError = (e: unknown) => + toast.error(extractErrorMessage(e, "Could not update the charge")); + + const create = useMutation({ + mutationFn: (p: { + reason: string; + amount: number; + currency: string; + action: "draft" | "send"; + file?: File | null; + }) => bookingsService.createAdditionalCharge(bookingId, p), + onSuccess: (next, p) => { + toast.success(p.action === "send" ? "Charge sent to the customer" : "Draft saved"); + refresh(next); + setModalOpen(false); + }, + onError, + }); + const send = useMutation({ + mutationFn: (chargeId: string) => bookingsService.sendAdditionalCharge(bookingId, chargeId), + onSuccess: (next) => { + toast.success("Charge sent to the customer"); + refresh(next); + }, + onError, + }); + const cancel = useMutation({ + mutationFn: (chargeId: string) => bookingsService.cancelAdditionalCharge(bookingId, chargeId), + onSuccess: (next) => { + toast.success("Charge cancelled"); + refresh(next); + }, + onError, + }); + + if (isLoading) { + return ( + + + Loading additional charges… + + ); + } + + const rows = charges ?? []; + const busy = send.isPending || cancel.isPending; + + return ( + + + + Additional charges + + + + + {rows.length === 0 && ( + + No additional charges raised on this booking yet. + + )} + + {rows.map((charge) => ( + send.mutate(charge.id)} + onCancel={() => cancel.mutate(charge.id)} + /> + ))} + + setModalOpen(false)} + busy={create.isPending} + onSubmit={(p) => create.mutate(p)} + /> + + ); +} + +function ChargeCard({ + charge, + busy, + onViewFile, + onSend, + onCancel, +}: { + charge: Freight.AdditionalCharge; + busy: boolean; + onViewFile: (file: { name: string; url: string }) => void; + onSend: () => void; + onCancel: () => void; +}) { + const meta = STATUS_META[charge.status]; + + return ( + + + + + + + {charge.reason} + + + Raised{charge.createdByName ? ` by ${charge.createdByName}` : ""} ·{" "} + {formatDateTime(charge.createdAt)} + + {charge.sentAt && ( + + Sent{charge.sentByName ? ` by ${charge.sentByName}` : ""} ·{" "} + {formatDateTime(charge.sentAt)} + {charge.paymentReference ? ` · ref ${charge.paymentReference}` : ""} + + )} + {charge.paidAt && ( + + Paid · {formatDateTime(charge.paidAt)} + {charge.invoiceNumber ? ` (invoice ${charge.invoiceNumber})` : ""} + + )} + {charge.cancelledAt && ( + + Cancelled · {formatDateTime(charge.cancelledAt)} + {charge.cancelReason ? ` — ${charge.cancelReason}` : ""} + + )} + + + + + {charge.amount.toLocaleString(undefined, { minimumFractionDigits: 2 })}{" "} + {charge.currency} + + + {meta.label} + + + + + {charge.file && ( + + + + {charge.file.name} + + {isViewable({ name: charge.file.name, url: "" }) && ( + + + void fetchViewableFile(charge.file!.id, charge.file!.name).then(onViewFile) + } + c="edr-green" + style={{ display: "flex", background: "transparent", border: "none", cursor: "pointer" }} + > + + + + )} + + void downloadBookingFile(charge.file!.id, charge.file!.name)} + c="edr-green" + style={{ display: "flex", background: "transparent", border: "none", cursor: "pointer" }} + > + + + + + )} + + {(charge.status === "DRAFT" || charge.status === "SENT") && ( + + + {charge.status === "DRAFT" && ( + + )} + + )} + + ); +} + +function AddChargeModal({ + opened, + onClose, + busy, + onSubmit, +}: { + opened: boolean; + onClose: () => void; + busy: boolean; + onSubmit: (p: { + reason: string; + amount: number; + currency: string; + action: "draft" | "send"; + file?: File | null; + }) => void; +}) { + const [reason, setReason] = useState(""); + const [amount, setAmount] = useState(""); + const [currency, setCurrency] = useState("ETB"); + const [file, setFile] = useState(null); + + const valid = reason.trim().length > 0 && Number(amount) > 0; + + const reset = () => { + setReason(""); + setAmount(""); + setCurrency("ETB"); + setFile(null); + }; + + const submit = (action: "draft" | "send") => { + if (!valid) return; + onSubmit({ reason: reason.trim(), amount: Number(amount), currency, action, file }); + }; + + return ( + { + onClose(); + reset(); + }} + title="Add additional charge" + radius="md" + centered + > + +