mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +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:
@@ -1222,11 +1222,14 @@ export class BookingsController {
|
|||||||
@Param("id", ParseUUIDPipe) id: string,
|
@Param("id", ParseUUIDPipe) id: string,
|
||||||
@CurrentUser() user: TCurrentUser,
|
@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);
|
const booking = await this.bookingsService.findById(id);
|
||||||
await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking);
|
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")
|
@Post(":id/additional-charges")
|
||||||
|
|||||||
@@ -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<Freight.AdditionalChargeStatus, { label: string; color: string }> = {
|
||||||
|
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 (
|
||||||
|
<Group justify="center" py="xl" gap={10}>
|
||||||
|
<Loader size="sm" color="edr-green" />
|
||||||
|
<Text c="dimmed">Loading additional charges…</Text>
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = charges ?? [];
|
||||||
|
const busy = send.isPending || cancel.isPending;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap="md" maw={860}>
|
||||||
|
<Group justify="space-between">
|
||||||
|
<Text fz="13px" fw={700} c="edr-text">
|
||||||
|
Additional charges
|
||||||
|
</Text>
|
||||||
|
<Button
|
||||||
|
size="compact-sm"
|
||||||
|
color="edr-green"
|
||||||
|
radius="md"
|
||||||
|
leftSection={<Plus size={14} />}
|
||||||
|
onClick={() => setModalOpen(true)}
|
||||||
|
>
|
||||||
|
Add charge
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{rows.length === 0 && (
|
||||||
|
<Text fz="12.5px" c="dimmed">
|
||||||
|
No additional charges raised on this booking yet.
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{rows.map((charge) => (
|
||||||
|
<ChargeCard
|
||||||
|
key={charge.id}
|
||||||
|
charge={charge}
|
||||||
|
busy={busy}
|
||||||
|
onViewFile={onViewFile}
|
||||||
|
onSend={() => send.mutate(charge.id)}
|
||||||
|
onCancel={() => cancel.mutate(charge.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<AddChargeModal
|
||||||
|
opened={modalOpen}
|
||||||
|
onClose={() => setModalOpen(false)}
|
||||||
|
busy={create.isPending}
|
||||||
|
onSubmit={(p) => create.mutate(p)}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<Paper withBorder radius="md" p="md">
|
||||||
|
<Group justify="space-between" wrap="nowrap" align="flex-start">
|
||||||
|
<Group gap={10} wrap="nowrap" align="flex-start">
|
||||||
|
<Receipt size={18} color="var(--mantine-color-edr-green-6)" />
|
||||||
|
<Box>
|
||||||
|
<Text fz="14px" fw={700} c="edr-text">
|
||||||
|
{charge.reason}
|
||||||
|
</Text>
|
||||||
|
<Text fz="11.5px" c="dimmed">
|
||||||
|
Raised{charge.createdByName ? ` by ${charge.createdByName}` : ""} ·{" "}
|
||||||
|
{formatDateTime(charge.createdAt)}
|
||||||
|
</Text>
|
||||||
|
{charge.sentAt && (
|
||||||
|
<Text fz="11.5px" c="dimmed">
|
||||||
|
Sent{charge.sentByName ? ` by ${charge.sentByName}` : ""} ·{" "}
|
||||||
|
{formatDateTime(charge.sentAt)}
|
||||||
|
{charge.paymentReference ? ` · ref ${charge.paymentReference}` : ""}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{charge.paidAt && (
|
||||||
|
<Text fz="11.5px" c="edr-green.8" fw={600}>
|
||||||
|
Paid · {formatDateTime(charge.paidAt)}
|
||||||
|
{charge.invoiceNumber ? ` (invoice ${charge.invoiceNumber})` : ""}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{charge.cancelledAt && (
|
||||||
|
<Text fz="11.5px" c="red.7">
|
||||||
|
Cancelled · {formatDateTime(charge.cancelledAt)}
|
||||||
|
{charge.cancelReason ? ` — ${charge.cancelReason}` : ""}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Group>
|
||||||
|
<Group gap={8} wrap="nowrap">
|
||||||
|
<Text fz="14px" fw={800} c="edr-text">
|
||||||
|
{charge.amount.toLocaleString(undefined, { minimumFractionDigits: 2 })}{" "}
|
||||||
|
{charge.currency}
|
||||||
|
</Text>
|
||||||
|
<Badge variant="light" color={meta.color} radius="sm">
|
||||||
|
{meta.label}
|
||||||
|
</Badge>
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{charge.file && (
|
||||||
|
<Group gap={8} mt="sm" wrap="nowrap">
|
||||||
|
<FileText size={15} color="var(--mantine-color-edr-green-6)" />
|
||||||
|
<Text fz="12.5px" c="edr-text" truncate style={{ minWidth: 0 }}>
|
||||||
|
{charge.file.name}
|
||||||
|
</Text>
|
||||||
|
{isViewable({ name: charge.file.name, url: "" }) && (
|
||||||
|
<Tooltip label="View">
|
||||||
|
<Box
|
||||||
|
component="button"
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
void fetchViewableFile(charge.file!.id, charge.file!.name).then(onViewFile)
|
||||||
|
}
|
||||||
|
c="edr-green"
|
||||||
|
style={{ display: "flex", background: "transparent", border: "none", cursor: "pointer" }}
|
||||||
|
>
|
||||||
|
<Eye size={15} />
|
||||||
|
</Box>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
<Tooltip label="Download">
|
||||||
|
<Box
|
||||||
|
component="button"
|
||||||
|
type="button"
|
||||||
|
onClick={() => void downloadBookingFile(charge.file!.id, charge.file!.name)}
|
||||||
|
c="edr-green"
|
||||||
|
style={{ display: "flex", background: "transparent", border: "none", cursor: "pointer" }}
|
||||||
|
>
|
||||||
|
<Download size={15} />
|
||||||
|
</Box>
|
||||||
|
</Tooltip>
|
||||||
|
</Group>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(charge.status === "DRAFT" || charge.status === "SENT") && (
|
||||||
|
<Group mt="sm" gap={8} justify="flex-end">
|
||||||
|
<Button
|
||||||
|
size="compact-sm"
|
||||||
|
variant="light"
|
||||||
|
color="red"
|
||||||
|
radius="md"
|
||||||
|
leftSection={<Ban size={14} />}
|
||||||
|
disabled={busy}
|
||||||
|
onClick={onCancel}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
{charge.status === "DRAFT" && (
|
||||||
|
<Button
|
||||||
|
size="compact-sm"
|
||||||
|
color="edr-green"
|
||||||
|
radius="md"
|
||||||
|
leftSection={<Send size={14} />}
|
||||||
|
disabled={busy}
|
||||||
|
onClick={onSend}
|
||||||
|
>
|
||||||
|
Send to customer
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
)}
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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<number | string>("");
|
||||||
|
const [currency, setCurrency] = useState("ETB");
|
||||||
|
const [file, setFile] = useState<File | null>(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 (
|
||||||
|
<Modal
|
||||||
|
opened={opened}
|
||||||
|
onClose={() => {
|
||||||
|
onClose();
|
||||||
|
reset();
|
||||||
|
}}
|
||||||
|
title="Add additional charge"
|
||||||
|
radius="md"
|
||||||
|
centered
|
||||||
|
>
|
||||||
|
<Stack gap="sm">
|
||||||
|
<Textarea
|
||||||
|
label="Reason for charge"
|
||||||
|
placeholder="e.g. Re-weighing fee at Mojo dry port"
|
||||||
|
autosize
|
||||||
|
minRows={2}
|
||||||
|
value={reason}
|
||||||
|
onChange={(e) => setReason(e.currentTarget.value)}
|
||||||
|
/>
|
||||||
|
<Group gap={8} align="flex-end">
|
||||||
|
<NumberInput
|
||||||
|
label="Amount"
|
||||||
|
min={0.01}
|
||||||
|
decimalScale={2}
|
||||||
|
value={amount}
|
||||||
|
onChange={setAmount}
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
label="Currency"
|
||||||
|
data={CURRENCIES}
|
||||||
|
value={currency}
|
||||||
|
onChange={(v) => v && setCurrency(v)}
|
||||||
|
w={100}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
<FileButton onChange={setFile} accept="application/pdf,image/*">
|
||||||
|
{(props) => (
|
||||||
|
<Button
|
||||||
|
{...props}
|
||||||
|
variant="light"
|
||||||
|
color="edr-green"
|
||||||
|
radius="md"
|
||||||
|
leftSection={<Upload size={14} />}
|
||||||
|
>
|
||||||
|
{file ? file.name : "Attach a document (optional)"}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</FileButton>
|
||||||
|
|
||||||
|
<Group justify="flex-end" mt="sm" gap={8}>
|
||||||
|
<Button
|
||||||
|
variant="light"
|
||||||
|
color="gray"
|
||||||
|
radius="md"
|
||||||
|
disabled={busy || !valid}
|
||||||
|
loading={busy}
|
||||||
|
onClick={() => submit("draft")}
|
||||||
|
>
|
||||||
|
Save draft
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
color="edr-green"
|
||||||
|
radius="md"
|
||||||
|
leftSection={<Send size={14} />}
|
||||||
|
disabled={busy || !valid}
|
||||||
|
loading={busy}
|
||||||
|
onClick={() => submit("send")}
|
||||||
|
>
|
||||||
|
Send to customer
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
Milestone,
|
Milestone,
|
||||||
MoreHorizontal,
|
MoreHorizontal,
|
||||||
Package,
|
Package,
|
||||||
|
Receipt,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
Ship,
|
Ship,
|
||||||
Truck,
|
Truck,
|
||||||
@@ -64,6 +65,7 @@ import {
|
|||||||
ContractOrdersPanel,
|
ContractOrdersPanel,
|
||||||
} from "@/components/bookings/detail";
|
} from "@/components/bookings/detail";
|
||||||
import { WarehouseInfoCard } from "@/components/warehouses";
|
import { WarehouseInfoCard } from "@/components/warehouses";
|
||||||
|
import { AdditionalPaymentsTab } from "@/components/bookings/AdditionalPaymentsTab";
|
||||||
import { getStatusMeta } from "@/features/bookings/booking-status.config";
|
import { getStatusMeta } from "@/features/bookings/booking-status.config";
|
||||||
import { toBookingListRow } from "@/features/bookings/mapBookingListRow";
|
import { toBookingListRow } from "@/features/bookings/mapBookingListRow";
|
||||||
import { formatDateTime, formatMoney } from "@/lib/format";
|
import { formatDateTime, formatMoney } from "@/lib/format";
|
||||||
@@ -74,7 +76,10 @@ import {
|
|||||||
useBookingMutations,
|
useBookingMutations,
|
||||||
} from "@/hooks/bookings/useBookings";
|
} from "@/hooks/bookings/useBookings";
|
||||||
import { useScrollToHash } from "@/hooks/useScrollToHash";
|
import { useScrollToHash } from "@/hooks/useScrollToHash";
|
||||||
|
import { useFileViewer } from "@/hooks/useFileViewer";
|
||||||
import { bookingsService } from "@/services/bookings.service";
|
import { bookingsService } from "@/services/bookings.service";
|
||||||
|
import { useAuth } from "@/auth/useAuth";
|
||||||
|
import { FREIGHT_PERMS, hasPermission as hasFreightPermission } from "@/lib/permissions";
|
||||||
|
|
||||||
export default function BookingRequestDetailPage() {
|
export default function BookingRequestDetailPage() {
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
@@ -82,6 +87,12 @@ export default function BookingRequestDetailPage() {
|
|||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
// Deep-link from a warehouse fee invoice → this booking's warehouse section.
|
// Deep-link from a warehouse fee invoice → this booking's warehouse section.
|
||||||
useScrollToHash();
|
useScrollToHash();
|
||||||
|
const { view, viewer } = useFileViewer();
|
||||||
|
const { user } = useAuth();
|
||||||
|
const canSeeAdditionalCharges = hasFreightPermission(
|
||||||
|
user,
|
||||||
|
FREIGHT_PERMS.additionalCharges.view,
|
||||||
|
);
|
||||||
|
|
||||||
// Consolidated pair: `?booking=<partnerId>` swaps the WHOLE page over to the
|
// Consolidated pair: `?booking=<partnerId>` swaps the WHOLE page over to the
|
||||||
// other half of the shared wagon. Everything below — KPIs, stepper, the
|
// other half of the shared wagon. Everything below — KPIs, stepper, the
|
||||||
@@ -206,7 +217,9 @@ export default function BookingRequestDetailPage() {
|
|||||||
? "documents"
|
? "documents"
|
||||||
: requestedTab === "trucks"
|
: requestedTab === "trucks"
|
||||||
? "trucks"
|
? "trucks"
|
||||||
: "overview";
|
: requestedTab === "additional-charges"
|
||||||
|
? "additional-charges"
|
||||||
|
: "overview";
|
||||||
const setActiveTab = (tab: string | null) => {
|
const setActiveTab = (tab: string | null) => {
|
||||||
const next = new URLSearchParams(searchParams);
|
const next = new URLSearchParams(searchParams);
|
||||||
if (tab && tab !== "overview") next.set("tab", tab);
|
if (tab && tab !== "overview") next.set("tab", tab);
|
||||||
@@ -509,6 +522,14 @@ export default function BookingRequestDetailPage() {
|
|||||||
<Tabs.Tab value="trucks" leftSection={<Truck size={16} />}>
|
<Tabs.Tab value="trucks" leftSection={<Truck size={16} />}>
|
||||||
Trucks
|
Trucks
|
||||||
</Tabs.Tab>
|
</Tabs.Tab>
|
||||||
|
{canSeeAdditionalCharges && (
|
||||||
|
<Tabs.Tab
|
||||||
|
value="additional-charges"
|
||||||
|
leftSection={<Receipt size={16} />}
|
||||||
|
>
|
||||||
|
Additional payments
|
||||||
|
</Tabs.Tab>
|
||||||
|
)}
|
||||||
</Tabs.List>
|
</Tabs.List>
|
||||||
|
|
||||||
<Tabs.Panel value="overview">
|
<Tabs.Panel value="overview">
|
||||||
@@ -528,6 +549,11 @@ export default function BookingRequestDetailPage() {
|
|||||||
<Tabs.Panel value="trucks">
|
<Tabs.Panel value="trucks">
|
||||||
<BookingTrucksPanel bookingId={booking.id} />
|
<BookingTrucksPanel bookingId={booking.id} />
|
||||||
</Tabs.Panel>
|
</Tabs.Panel>
|
||||||
|
{canSeeAdditionalCharges && (
|
||||||
|
<Tabs.Panel value="additional-charges">
|
||||||
|
<AdditionalPaymentsTab bookingId={booking.id} onViewFile={view} />
|
||||||
|
</Tabs.Panel>
|
||||||
|
)}
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</Grid.Col>
|
</Grid.Col>
|
||||||
|
|
||||||
@@ -562,6 +588,7 @@ export default function BookingRequestDetailPage() {
|
|||||||
</Grid.Col>
|
</Grid.Col>
|
||||||
</Grid>
|
</Grid>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
{viewer}
|
||||||
</PageContainer>
|
</PageContainer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -508,6 +508,53 @@ export const bookingsService = {
|
|||||||
return unwrap(response.data) as Freight.ClearanceCharge[];
|
return unwrap(response.data) as Freight.ClearanceCharge[];
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// ── Additional charges (ad-hoc finance billing) ──
|
||||||
|
getAdditionalCharges: async (id: string): Promise<Freight.AdditionalCharge[]> => {
|
||||||
|
const response = await client.get(`/bookings/${id}/additional-charges`);
|
||||||
|
return unwrap(response.data) as Freight.AdditionalCharge[];
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Finance raises a new charge — 'draft' just saves it, 'send' also issues the invoice and notifies the customer. */
|
||||||
|
createAdditionalCharge: async (
|
||||||
|
id: string,
|
||||||
|
payload: { reason: string; amount: number; currency: string; action: "draft" | "send"; file?: File | null },
|
||||||
|
): Promise<Freight.AdditionalCharge[]> => {
|
||||||
|
const form = new FormData();
|
||||||
|
form.append("reason", payload.reason);
|
||||||
|
form.append("amount", String(payload.amount));
|
||||||
|
form.append("currency", payload.currency);
|
||||||
|
form.append("action", payload.action);
|
||||||
|
if (payload.file) form.append("file", payload.file);
|
||||||
|
const response = await client.post(`/bookings/${id}/additional-charges`, form, {
|
||||||
|
headers: { "Content-Type": "multipart/form-data" },
|
||||||
|
});
|
||||||
|
return unwrap(response.data) as Freight.AdditionalCharge[];
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Issues the draft charge's payable invoice and notifies the customer. */
|
||||||
|
sendAdditionalCharge: async (
|
||||||
|
id: string,
|
||||||
|
chargeId: string,
|
||||||
|
): Promise<Freight.AdditionalCharge[]> => {
|
||||||
|
const response = await client.post(
|
||||||
|
`/bookings/${id}/additional-charges/${chargeId}/send`,
|
||||||
|
);
|
||||||
|
return unwrap(response.data) as Freight.AdditionalCharge[];
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Withdraws a draft or unpaid additional charge. */
|
||||||
|
cancelAdditionalCharge: async (
|
||||||
|
id: string,
|
||||||
|
chargeId: string,
|
||||||
|
reason?: string,
|
||||||
|
): Promise<Freight.AdditionalCharge[]> => {
|
||||||
|
const response = await client.post(
|
||||||
|
`/bookings/${id}/additional-charges/${chargeId}/cancel`,
|
||||||
|
{ reason },
|
||||||
|
);
|
||||||
|
return unwrap(response.data) as Freight.AdditionalCharge[];
|
||||||
|
},
|
||||||
|
|
||||||
/** GL ET asks Djibouti to name the officer handling the shipment in transit. */
|
/** GL ET asks Djibouti to name the officer handling the shipment in transit. */
|
||||||
requestTransitAssignee: (id: string, note?: string) =>
|
requestTransitAssignee: (id: string, note?: string) =>
|
||||||
postBooking<BookingDetail>(B.CLEARANCE_TRANSIT_ASSIGNEE_REQUEST(id), {
|
postBooking<BookingDetail>(B.CLEARANCE_TRANSIT_ASSIGNEE_REQUEST(id), {
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ import {
|
|||||||
ConsolidationWaitingBanner,
|
ConsolidationWaitingBanner,
|
||||||
} from "./components/Notices";
|
} from "./components/Notices";
|
||||||
import { BookingPaymentPanel } from "./components/BookingPaymentPanel";
|
import { BookingPaymentPanel } from "./components/BookingPaymentPanel";
|
||||||
|
import { AdditionalChargesPanel } from "./components/AdditionalChargesPanel";
|
||||||
import { HeaderButton, PageHeader } from "./components/PageHeader";
|
import { HeaderButton, PageHeader } from "./components/PageHeader";
|
||||||
import { PaymentMethodModal } from "./components/PaymentMethodModal";
|
import { PaymentMethodModal } from "./components/PaymentMethodModal";
|
||||||
import { ScheduleCard } from "./components/ScheduleCard";
|
import { ScheduleCard } from "./components/ScheduleCard";
|
||||||
@@ -326,6 +327,7 @@ export function ReadonlyBookingView({
|
|||||||
paying={pay.processing}
|
paying={pay.processing}
|
||||||
showCountdown={showCountdown}
|
showCountdown={showCountdown}
|
||||||
/>
|
/>
|
||||||
|
<AdditionalChargesPanel bookingId={booking.id} />
|
||||||
<ScheduleCard
|
<ScheduleCard
|
||||||
booking={booking}
|
booking={booking}
|
||||||
title="Consignment & Schedule"
|
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`);
|
const { data } = await client.get(`/api/bookings/${id}/mile-summary`);
|
||||||
return data.data;
|
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 (
|
assignCustomerTruck: async (
|
||||||
id: string,
|
id: string,
|
||||||
payload: CustomerTruckAssignmentPayload,
|
payload: CustomerTruckAssignmentPayload,
|
||||||
|
|||||||
Reference in New Issue
Block a user